הרכיב עצמאי ופועל ללא GSAP או ספריות חיצוניות. אפשר להשתמש בו לניווט בין פרקי סיפור, קטגוריות או מקטעים בעמוד.
בקונטיינרים של ארבעת הפרקים הגדירי CSS ID לפי הטבלה. ללא הסימן #.
הוסיפי ווידג׳ט HTML אחד והדביקי את קוד ה־HTML שבתיבה הראשונה.
הוסיפי את ה־CSS לאזור הסטייל של עמוד ההדגמה או לקוד הגלובלי של ה־LAB.
הדביקי את ה־JS שבתיבה השלישית פעם אחת, לאחר ה־HTML. תגי script כבר כלולים.
התאימי את --zkl-scroll-offset לגובה ההדר ולמרווח שמעל התוכן. ברירת המחדל לרכיב היא 182px; בדמו הזה ההדר קטן יותר.
פרק
CSS ID של הקונטיינר
קישור התחנה
הרעיון
lab-story-01
#lab-story-01
הכיוון
lab-story-02
#lab-story-02
התנועה
lab-story-03
#lab-story-03
החיבור
lab-story-04
#lab-story-04
ב־LAB השתמשי במסילה החדשה במקום המסילה הדקורטיבית הישנה ובמקום סקריפט האור הישן. תפריט הצד הוא רכיב נפרד. בדקי שמיקום לשונית פתיחת התפריט אינו מכסה תחנה; אפשר לשנות את תחילת המסלול ואת סופו במשתנים שלמטה.
<script>
(() => {
'use strict';
// Safe to paste once in a global footer, or below the HTML widget.
window.ZakaiChapterRail?.destroy();
const cleanups = [];
let destroyed = false;
function init() {
if (destroyed) return;
document.querySelectorAll('.zkl-chapter-rail').forEach(rail => {
if (rail.dataset.ready) return;
const rootId = rail.getAttribute('data-scroll-container');
const scroller = rootId ? document.getElementById(rootId) : null;
if (rootId && !scroller) return;
// Elementor demo wrappers may have backdrop-filter/transform/overflow.
// A page-level fixed rail must live outside those containing blocks.
if (!scroller && rail.parentElement !== document.body) {
document.body.appendChild(rail);
}
const root = scroller || window;
const path = rail.querySelector('.zkl-chapter-path');
const fill = rail.querySelector('.zkl-chapter-fill');
const trail = rail.querySelector('.zkl-chapter-trail');
const head = rail.querySelector('.zkl-chapter-head');
if (!path || !fill || !trail || !head) return;
const allLinks = [...rail.querySelectorAll('.zkl-chapter-stop')];
const items = allLinks.map(link => {
const href = link.getAttribute('href') || '';
let section = null;
try { section = href.startsWith('#') ? document.getElementById(decodeURIComponent(href.slice(1))) : null; } catch (_) {}
link.hidden = !section || !!(scroller && !scroller.contains(section));
return {link, section, top: 0};
}).filter(item => !item.link.hidden);
if (!items.length) { rail.hidden = true; return; }
rail.hidden = false;
rail.dataset.ready = 'true';
const motion = matchMedia('(prefers-reduced-motion: reduce)');
let length = path.getTotalLength(), dirty = true, raf = 0, current = -1;
let offset = 0, pending = null;
const y = () => scroller ? scroller.scrollTop : (document.scrollingElement || document.documentElement).scrollTop;
const maxY = () => Math.max(0, scroller ? scroller.scrollHeight - scroller.clientHeight : (document.scrollingElement || document.documentElement).scrollHeight - innerHeight);
function measure() {
offset = Math.max(0, parseFloat(getComputedStyle(rail).getPropertyValue('--zkl-scroll-offset')) || 0);
const origin = scroller ? scroller.getBoundingClientRect().top + scroller.clientTop : 0;
const scroll = y(), max = maxY();
items.forEach(item => { item.top = Math.min(max, Math.max(0, item.section.getBoundingClientRect().top - origin + scroll - offset)); });
// Links should follow the visual order of the chapters.
items.sort((a, b) => a.top - b.top);
length = path.getTotalLength();
items.forEach((item, i) => {
const point = path.getPointAtLength(items.length === 1 ? 0 : length * i / (items.length - 1));
item.link.style.left = `${point.x / 28 * 100}%`;
item.link.style.top = `${point.y / 1000 * 100}%`;
});
dirty = false;
}
function finishFocus(item) {
const heading = item.section.querySelector('[data-zkl-chapter-heading], h1, h2, h3') || item.section;
if (!heading.hasAttribute('tabindex')) heading.setAttribute('tabindex', '-1');
heading.focus({preventScroll: true});
}
function render() {
raf = 0;
if (dirty) measure();
const scroll = y();
let index = 0;
for (let i = 1; i < items.length; i++) if (scroll >= items[i].top - 2) index = i;
const next = items[index + 1];
const span = next ? next.top - items[index].top : 0;
const t = next && span > 0 ? Math.max(0, Math.min(1, (scroll - items[index].top) / span)) : 0;
const p = items.length > 1 ? (index + t) / (items.length - 1) : 0;
const point = path.getPointAtLength(length * p);
head.setAttribute('cx', point.x); head.setAttribute('cy', point.y);
fill.style.strokeDasharray = `${length} ${length}`;
fill.style.strokeDashoffset = String(length * (1 - p));
const tail = Math.min(75, length * p);
trail.style.strokeDasharray = `${tail} ${length}`;
trail.style.strokeDashoffset = String(-Math.max(0, length * p - tail));
rail.dataset.progress = p.toFixed(4);
if (index !== current) {
current = index;
items.forEach((item, i) => {
if (i === index) item.link.setAttribute('aria-current', 'location');
else item.link.removeAttribute('aria-current');
item.link.classList.toggle('is-past', i < index);
});
rail.dataset.chapter = String(index + 1);
const name = items[index].link.querySelector('.zkl-chapter-label')?.textContent || items[index].section.id;
rail.dispatchEvent(new CustomEvent('zkl:chapterchange', {bubbles: true, detail: {index, total: items.length, name, id: items[index].section.id}}));
}
if (pending && Math.abs(scroll - pending.top) < 3) {
finishFocus(pending); pending = null;
}
}
function schedule() { if (!raf) raf = requestAnimationFrame(render); }
function resize() { dirty = true; schedule(); }
function cancelPending() { pending = null; }
const clicks = items.map(item => {
function click(event) {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
event.preventDefault(); measure(); pending = item;
root.scrollTo({top: item.top, behavior: motion.matches ? 'instant' : 'smooth'});
if (!scroller) { try { history.replaceState(null, '', '#' + encodeURIComponent(item.section.id)); } catch (_) {} }
schedule();
}
item.link.addEventListener('click', click);
return () => item.link.removeEventListener('click', click);
});
root.addEventListener('scroll', schedule, {passive: true});
root.addEventListener('wheel', cancelPending, {passive: true});
root.addEventListener('touchstart', cancelPending, {passive: true});
addEventListener('resize', resize);
addEventListener('pageshow', resize);
motion.addEventListener('change', resize);
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
observer?.observe(scroller || document.body);
items.forEach(item => observer?.observe(item.section));
document.fonts?.ready.then(() => { if (!destroyed) resize(); });
render();
cleanups.push(() => {
cancelAnimationFrame(raf); observer?.disconnect(); clicks.forEach(fn => fn());
root.removeEventListener('scroll', schedule);
root.removeEventListener('wheel', cancelPending);
root.removeEventListener('touchstart', cancelPending);
removeEventListener('resize', resize); removeEventListener('pageshow', resize);
motion.removeEventListener('change', resize); delete rail.dataset.ready;
});
});
}
window.ZakaiChapterRail = {
init,
refresh() { dispatchEvent(new Event('resize')); },
destroy() { destroyed = true; document.removeEventListener('DOMContentLoaded', init); cleanups.splice(0).forEach(fn => fn()); }
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, {once: true});
else init();
})();
</script>
מה ניתן לשינוי
שמות ומספר הפרקים: ערכי, הוסיפי או הסירי קישורי zkl-chapter-stop ב־HTML. לכל תחנה צריך להיות קונטיינר בעל ID תואם. הסקריפט מסדר את התחנות לפי מיקום הקונטיינרים בפועל.
צבע האור והמסילה: המשתנים --zkl-gold, --zkl-light ו־--zkl-rail-surface.
רוחב המסילה:--zkl-rail-width, ברירת מחדל 56px במחשב ו־36px בנייד. שטח הלחיצה של התחנות הוא 44px.
מרווח עליון ותחתון למסלול:--zkl-rail-top ו־--zkl-rail-bottom. אלו מזיזים את התחנות בתוך המסילה.
מרווח בגלילה לפרק:--zkl-scroll-offset. אפשר להגדיר ערך שונה במחשב ובנייד.
מסילה ישרה: החליפי את d בשלושת המסלולים ב־M14 18 L14 982.
שם התחנה: כל שמות הפרקים גלויים במחשב. בנייד שמות התחנות זמינים בלחיצה ובניווט מקלדת בלי להשאיר תווית מעל התוכן.
תנועה מופחתת: מעבר ישיר לפרק, בלי גלילה חלקה ובלי שובל אור. סימון הפרק ממשיך לעבוד.
אחרי שינוי גובה התוכן: מתעדכן אוטומטית עם ResizeObserver. אם תוסף משנה את מיקום המקטעים בלי לשנות גובה, אפשר לקרוא ל־window.ZakaiChapterRail.refresh().
העמוד הוא דוגמה עצמאית. האתר החי לא נערך במסגרת ההכנה.
ZAKAI LAB SCROLL STORIES / CHAPTER LIGHT RAIL
01 / 04 · הרעיון
01 / הרעיון
כל סיפור מתחיל בנקודה.
גללו מטה. האור במסילה מתקדם עם הסיפור, והתחנה הפעילה מסמנת היכן אנחנו נמצאים.
הרכיב עצמאי ופועל ללא GSAP או ספריות חיצוניות. אפשר להשתמש בו לניווט בין פרקי סיפור, קטגוריות או מקטעים בעמוד.
בקונטיינרים של ארבעת הפרקים הגדירי CSS ID לפי הטבלה. ללא הסימן #.
הוסיפי ווידג׳ט HTML אחד והדביקי את קוד ה־HTML שבתיבה הראשונה.
הוסיפי את ה־CSS לאזור הסטייל של עמוד ההדגמה או לקוד הגלובלי של ה־LAB.
הדביקי את ה־JS שבתיבה השלישית פעם אחת, לאחר ה־HTML. תגי script כבר כלולים.
התאימי את --zkl-scroll-offset לגובה ההדר ולמרווח שמעל התוכן. ברירת המחדל לרכיב היא 182px; בדמו הזה ההדר קטן יותר.
פרק
CSS ID של הקונטיינר
קישור התחנה
הרעיון
lab-story-01
#lab-story-01
הכיוון
lab-story-02
#lab-story-02
התנועה
lab-story-03
#lab-story-03
החיבור
lab-story-04
#lab-story-04
ב־LAB השתמשי במסילה החדשה במקום המסילה הדקורטיבית הישנה ובמקום סקריפט האור הישן. תפריט הצד הוא רכיב נפרד. בדקי שמיקום לשונית פתיחת התפריט אינו מכסה תחנה; אפשר לשנות את תחילת המסלול ואת סופו במשתנים שלמטה.
<script>
(() => {
'use strict';
// Safe to paste once in a global footer, or below the HTML widget.
window.ZakaiChapterRail?.destroy();
const cleanups = [];
let destroyed = false;
function init() {
if (destroyed) return;
document.querySelectorAll('.zkl-chapter-rail').forEach(rail => {
if (rail.dataset.ready) return;
const rootId = rail.getAttribute('data-scroll-container');
const scroller = rootId ? document.getElementById(rootId) : null;
if (rootId && !scroller) return;
// Elementor demo wrappers may have backdrop-filter/transform/overflow.
// A page-level fixed rail must live outside those containing blocks.
if (!scroller && rail.parentElement !== document.body) {
document.body.appendChild(rail);
}
const root = scroller || window;
const path = rail.querySelector('.zkl-chapter-path');
const fill = rail.querySelector('.zkl-chapter-fill');
const trail = rail.querySelector('.zkl-chapter-trail');
const head = rail.querySelector('.zkl-chapter-head');
if (!path || !fill || !trail || !head) return;
const allLinks = [...rail.querySelectorAll('.zkl-chapter-stop')];
const items = allLinks.map(link => {
const href = link.getAttribute('href') || '';
let section = null;
try { section = href.startsWith('#') ? document.getElementById(decodeURIComponent(href.slice(1))) : null; } catch (_) {}
link.hidden = !section || !!(scroller && !scroller.contains(section));
return {link, section, top: 0};
}).filter(item => !item.link.hidden);
if (!items.length) { rail.hidden = true; return; }
rail.hidden = false;
rail.dataset.ready = 'true';
const motion = matchMedia('(prefers-reduced-motion: reduce)');
let length = path.getTotalLength(), dirty = true, raf = 0, current = -1;
let offset = 0, pending = null;
const y = () => scroller ? scroller.scrollTop : (document.scrollingElement || document.documentElement).scrollTop;
const maxY = () => Math.max(0, scroller ? scroller.scrollHeight - scroller.clientHeight : (document.scrollingElement || document.documentElement).scrollHeight - innerHeight);
function measure() {
offset = Math.max(0, parseFloat(getComputedStyle(rail).getPropertyValue('--zkl-scroll-offset')) || 0);
const origin = scroller ? scroller.getBoundingClientRect().top + scroller.clientTop : 0;
const scroll = y(), max = maxY();
items.forEach(item => { item.top = Math.min(max, Math.max(0, item.section.getBoundingClientRect().top - origin + scroll - offset)); });
// Links should follow the visual order of the chapters.
items.sort((a, b) => a.top - b.top);
length = path.getTotalLength();
items.forEach((item, i) => {
const point = path.getPointAtLength(items.length === 1 ? 0 : length * i / (items.length - 1));
item.link.style.left = `${point.x / 28 * 100}%`;
item.link.style.top = `${point.y / 1000 * 100}%`;
});
dirty = false;
}
function finishFocus(item) {
const heading = item.section.querySelector('[data-zkl-chapter-heading], h1, h2, h3') || item.section;
if (!heading.hasAttribute('tabindex')) heading.setAttribute('tabindex', '-1');
heading.focus({preventScroll: true});
}
function render() {
raf = 0;
if (dirty) measure();
const scroll = y();
let index = 0;
for (let i = 1; i < items.length; i++) if (scroll >= items[i].top - 2) index = i;
const next = items[index + 1];
const span = next ? next.top - items[index].top : 0;
const t = next && span > 0 ? Math.max(0, Math.min(1, (scroll - items[index].top) / span)) : 0;
const p = items.length > 1 ? (index + t) / (items.length - 1) : 0;
const point = path.getPointAtLength(length * p);
head.setAttribute('cx', point.x); head.setAttribute('cy', point.y);
fill.style.strokeDasharray = `${length} ${length}`;
fill.style.strokeDashoffset = String(length * (1 - p));
const tail = Math.min(75, length * p);
trail.style.strokeDasharray = `${tail} ${length}`;
trail.style.strokeDashoffset = String(-Math.max(0, length * p - tail));
rail.dataset.progress = p.toFixed(4);
if (index !== current) {
current = index;
items.forEach((item, i) => {
if (i === index) item.link.setAttribute('aria-current', 'location');
else item.link.removeAttribute('aria-current');
item.link.classList.toggle('is-past', i < index);
});
rail.dataset.chapter = String(index + 1);
const name = items[index].link.querySelector('.zkl-chapter-label')?.textContent || items[index].section.id;
rail.dispatchEvent(new CustomEvent('zkl:chapterchange', {bubbles: true, detail: {index, total: items.length, name, id: items[index].section.id}}));
}
if (pending && Math.abs(scroll - pending.top) < 3) {
finishFocus(pending); pending = null;
}
}
function schedule() { if (!raf) raf = requestAnimationFrame(render); }
function resize() { dirty = true; schedule(); }
function cancelPending() { pending = null; }
const clicks = items.map(item => {
function click(event) {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
event.preventDefault(); measure(); pending = item;
root.scrollTo({top: item.top, behavior: motion.matches ? 'instant' : 'smooth'});
if (!scroller) { try { history.replaceState(null, '', '#' + encodeURIComponent(item.section.id)); } catch (_) {} }
schedule();
}
item.link.addEventListener('click', click);
return () => item.link.removeEventListener('click', click);
});
root.addEventListener('scroll', schedule, {passive: true});
root.addEventListener('wheel', cancelPending, {passive: true});
root.addEventListener('touchstart', cancelPending, {passive: true});
addEventListener('resize', resize);
addEventListener('pageshow', resize);
motion.addEventListener('change', resize);
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
observer?.observe(scroller || document.body);
items.forEach(item => observer?.observe(item.section));
document.fonts?.ready.then(() => { if (!destroyed) resize(); });
render();
cleanups.push(() => {
cancelAnimationFrame(raf); observer?.disconnect(); clicks.forEach(fn => fn());
root.removeEventListener('scroll', schedule);
root.removeEventListener('wheel', cancelPending);
root.removeEventListener('touchstart', cancelPending);
removeEventListener('resize', resize); removeEventListener('pageshow', resize);
motion.removeEventListener('change', resize); delete rail.dataset.ready;
});
});
}
window.ZakaiChapterRail = {
init,
refresh() { dispatchEvent(new Event('resize')); },
destroy() { destroyed = true; document.removeEventListener('DOMContentLoaded', init); cleanups.splice(0).forEach(fn => fn()); }
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, {once: true});
else init();
})();
</script>
מה ניתן לשינוי
שמות ומספר הפרקים: ערכי, הוסיפי או הסירי קישורי zkl-chapter-stop ב־HTML. לכל תחנה צריך להיות קונטיינר בעל ID תואם. הסקריפט מסדר את התחנות לפי מיקום הקונטיינרים בפועל.
צבע האור והמסילה: המשתנים --zkl-gold, --zkl-light ו־--zkl-rail-surface.
רוחב המסילה:--zkl-rail-width, ברירת מחדל 56px במחשב ו־36px בנייד. שטח הלחיצה של התחנות הוא 44px.
מרווח עליון ותחתון למסלול:--zkl-rail-top ו־--zkl-rail-bottom. אלו מזיזים את התחנות בתוך המסילה.
מרווח בגלילה לפרק:--zkl-scroll-offset. אפשר להגדיר ערך שונה במחשב ובנייד.
מסילה ישרה: החליפי את d בשלושת המסלולים ב־M14 18 L14 982.
שם התחנה: כל שמות הפרקים גלויים במחשב. בנייד שמות התחנות זמינים בלחיצה ובניווט מקלדת בלי להשאיר תווית מעל התוכן.
תנועה מופחתת: מעבר ישיר לפרק, בלי גלילה חלקה ובלי שובל אור. סימון הפרק ממשיך לעבוד.
אחרי שינוי גובה התוכן: מתעדכן אוטומטית עם ResizeObserver. אם תוסף משנה את מיקום המקטעים בלי לשנות גובה, אפשר לקרוא ל־window.ZakaiChapterRail.refresh().
העמוד הוא דוגמה עצמאית. האתר החי לא נערך במסגרת ההכנה.