<nav class="supt-chapters-nav">
<div class="supt-chapters-nav__inner">
<ul class="supt-chapters-nav__list">
<li class="supt-chapters-nav__item">
<a href="#introduction" class="supt-chapters-nav__link" data-width="105">
<span class="supt-chapters-nav__link__number">
I
</span>
<span class="supt-chapters-nav__link__title">
Introduction
</span>
</a>
</li>
<li class="supt-chapters-nav__item">
<a href="#why-we-innovate" class="supt-chapters-nav__link" data-width="126">
<span class="supt-chapters-nav__link__number">
II
</span>
<span class="supt-chapters-nav__link__title">
Why we innovate
</span>
</a>
</li>
<li class="supt-chapters-nav__item">
<a href="#locations" class="supt-chapters-nav__link" data-width="82">
<span class="supt-chapters-nav__link__number">
III
</span>
<span class="supt-chapters-nav__link__title">
Locations
</span>
</a>
</li>
<li class="supt-chapters-nav__item">
<a href="#our-products" class="supt-chapters-nav__link" data-width="110">
<span class="supt-chapters-nav__link__number">
IV
</span>
<span class="supt-chapters-nav__link__title">
Our products
</span>
</a>
</li>
</ul>
<div class="supt-chapters-nav__progress">
<div class="supt-chapters-nav__progress__bar"></div>
</div>
</div>
</nav>
No notes defined.
{% set roman_numbers = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'] %}
<nav class="supt-chapters-nav">
<div class="supt-chapters-nav__inner">
<ul class="supt-chapters-nav__list">
{% for index, chapter in chapters %}
<li class="supt-chapters-nav__item">
<a href="#{{ chapter.id }}" class="supt-chapters-nav__link" data-width="{{ chapter.width }}">
<span class="supt-chapters-nav__link__number">
{{ roman_numbers[index] }}
{# {{ index < 9 ? '0' ~ (index + 1) : (index + 1) }} #}
</span>
<span class="supt-chapters-nav__link__title">
{{ chapter.title }}
</span>
</a>
</li>
{% endfor %}
</ul>
<div class="supt-chapters-nav__progress">
<div class="supt-chapters-nav__progress__bar"></div>
</div>
</div>
</nav>
{
"chapters": [
{
"title": "Introduction",
"id": "introduction",
"width": 105
},
{
"title": "Why we innovate",
"id": "why-we-innovate",
"width": 126
},
{
"title": "Locations",
"id": "locations",
"width": 82
},
{
"title": "Our products",
"id": "our-products",
"width": 110
}
]
}
.supt-chapters-nav {
position: fixed;
left: 0;
bottom: 0;
right: 0;
z-index: $z-index-chapters-nav;
padding: 14px 10px;
@media (min-width: $breakpoint-md) {
padding: 0;
bottom: 24px;
left: 32px;
right: auto;
}
&__inner {
overflow-x: auto;
/* Hide scrollbar */
&::-webkit-scrollbar {
display: none;
}
@media (min-width: $breakpoint-md) {
overflow-x: hidden;
}
}
&__list {
display: flex;
gap: $spacing-4;
}
&__item {
position: relative;
}
&__link {
font-family: $font-primary;
color: $color-white;
font-size: 12px;
line-height: 18px;
letter-spacing: 0.4px;
text-transform: uppercase;
display: flex;
gap: $spacing-1;
opacity: 0.5;
transition: opacity $transition-fast;
&:hover {
color: $color-white;
opacity: 1;
}
&.-is-active {
opacity: 1;
}
}
&__link__title {
white-space: nowrap;
@media (min-width: $breakpoint-md) {
will-change: opacity, max-width;
opacity: 0;
max-width: 0;
overflow: hidden;
}
}
&__progress {
margin-top: $spacing-1-5;
height: 2px;
background-color: rgb(255 255 255 / 50%);
position: absolute;
top: 0;
left: 0;
right: 0;
@media (min-width: $breakpoint-md) {
position: relative;
width: 100%;
margin-top: $spacing-1-5;
}
&__bar {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 1px;
background-color: $color-white;
transform-origin: left;
transform: scaleX(0);
}
}
}
import { Sizes } from '@/js/Sizes';
import { animate } from 'animejs';
const ANIMATION_DURATION = 500;
const STRETCHED_NUMBER_WIDTH = [0, 24, 26, 27];
export class ChaptersNav {
$element: HTMLElement;
$progressBar: HTMLElement;
$links: NodeListOf<HTMLElement>;
$titles: NodeListOf<HTMLElement>;
$inner: HTMLElement;
currentSectionIndex: number = 0;
observer: IntersectionObserver | null = null;
sizes: Sizes;
private scrollHandler: (() => void) | null = null;
constructor($element: HTMLElement) {
this.$element = $element;
this.$progressBar = $element.querySelector('.supt-chapters-nav__progress__bar') as HTMLElement;
this.$links = $element.querySelectorAll('.supt-chapters-nav__link');
this.$titles = $element.querySelectorAll('.supt-chapters-nav__link__title');
this.$inner = $element.querySelector('.supt-chapters-nav__inner') as HTMLElement;
this.sizes = new Sizes();
this.init();
}
private init(): void {
this.setupIntersectionObserver();
this.setupScrollHandler();
// Ensure first link is active by default
if (this.$links.length > 0) {
this.$links[0].classList.add('-is-active');
}
// Set initial state for titles
this.updateTitlesVisibility();
// Set initial progress bar position
this.updateProgressBar({ duration: 0 });
}
private setupIntersectionObserver(): void {
// Find all sections on the page that correspond to chapters
const sections = this.findChapterSections();
if (sections.length === 0) return;
// Configure intersection observer options
const options: IntersectionObserverInit = {
root: null, // Use viewport as root
rootMargin: '-20% 0px -20% 0px', // Trigger when element is in center 60% of viewport
threshold: 0, // Trigger as soon as any part enters the center area
};
// Create intersection observer
this.observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Get the section index from data attribute or class
const sectionElement = entry.target as HTMLElement;
const sectionIndex = this.getSectionIndex(sectionElement);
if (sectionIndex !== -1 && sectionIndex !== this.currentSectionIndex) {
this.currentSectionIndex = sectionIndex;
this.updateTitlesVisibility();
this.updateProgressBar();
// Auto-scroll to the active link
this.scrollToActiveLink();
}
}
});
}, options);
// Start observing all section elements
sections.forEach(section => {
this.observer?.observe(section);
});
}
private findChapterSections(): HTMLElement[] {
// Look for sections that might correspond to chapters
// This could be sections with specific classes or data attributes
const possibleSelectors = ['section[id]'];
for (const selector of possibleSelectors) {
const sections = document.querySelectorAll<HTMLElement>(selector);
if (sections.length > 0) {
return Array.from(sections);
}
}
// Fallback: look for any section elements
return Array.from(document.querySelectorAll<HTMLElement>('section'));
}
// Get Section Index from ID
private getSectionIndex(sectionElement: HTMLElement): number {
// Try to get from id
const id = sectionElement.id;
if (id) {
// Extract the first number from the ID
const match = id.match(/(\d+)/);
if (match) {
// Convert to 0-based index (subtract 1)
return parseInt(match[1], 10) - 1;
}
}
// Fallback: find position among all sections
const allSections = this.findChapterSections();
return allSections.indexOf(sectionElement);
}
private setupScrollHandler(): void {
// Only add scroll handler on mobile where horizontal scrolling is enabled
if (!this.sizes.isDesktop) {
this.scrollHandler = () => {
this.updateProgressBar({ duration: 0 });
};
this.$inner.addEventListener('scroll', this.scrollHandler);
}
}
private getProgressBarSizes(sectionIndex: number): { markerLeft: number; markerWidth: number } {
const $activeLink = this.$links[sectionIndex];
if (!$activeLink) return { markerLeft: 0, markerWidth: 0 };
const linkWidth = parseInt($activeLink.dataset.width || '0');
// Calculate the position of the marker relative to the container
let markerLeft = this.sizes.isDesktop
? STRETCHED_NUMBER_WIDTH[sectionIndex] * sectionIndex
: $activeLink.getBoundingClientRect().left;
let markerWidth = linkWidth;
if (!this.sizes.isDesktop) {
// Handle the left padding of the nav on mobile
if (this.currentSectionIndex == 0) {
markerLeft -= 10;
markerWidth += 10;
} else if (this.currentSectionIndex == this.$links.length - 1) {
markerWidth += 10;
}
}
return {
markerLeft,
markerWidth,
};
}
private updateProgressBar({ duration = 200 }: { duration?: number } = {}): void {
const { markerLeft, markerWidth } = this.getProgressBarSizes(this.currentSectionIndex);
// Animate the marker to the current link position
animate(this.$progressBar, {
x: markerLeft,
scaleX: markerWidth,
duration,
});
}
private updateTitlesVisibility(): void {
if (this.sizes.isDesktop) {
// Update current section title only on desktop
this.$titles.forEach((title, index) => {
if (index === this.currentSectionIndex) {
// Show current section title
animate(title, {
maxWidth: `${this.$links[index].dataset.width}px`,
opacity: [title.style.opacity || '0', 1],
duration: ANIMATION_DURATION,
});
} else {
// Hide other section titles
animate(title, {
maxWidth: '0px',
opacity: [title.style.opacity || '1', 0],
duration: ANIMATION_DURATION,
});
}
});
}
// Update active state of links
this.$links.forEach((link, index) => {
if (index === this.currentSectionIndex) {
link.classList.add('-is-active');
} else {
link.classList.remove('-is-active');
}
});
}
private scrollToActiveLink(): void {
if (this.sizes.isDesktop) return; // Only scroll on mobile
const $activeLink = this.$links[this.currentSectionIndex];
if (!$activeLink) return;
$activeLink.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}
destroy(): void {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
// Remove scroll event listener
if (this.scrollHandler && !this.sizes.isDesktop) {
this.$inner.removeEventListener('scroll', this.scrollHandler);
this.scrollHandler = null;
}
}
}