Scrollspy
Links to the sections of a page that mark the one in view as it scrolls, down the side or in a row.
Demo
Introduction
Introduction: a few paragraphs go here, long enough that the box scrolls and the list beside it follows the section in view.
More about introduction, with examples and notes.
Setup
Setup: a few paragraphs go here, long enough that the box scrolls and the list beside it follows the section in view.
More about setup, with examples and notes.
Usage
Usage: a few paragraphs go here, long enough that the box scrolls and the list beside it follows the section in view.
More about usage, with examples and notes.
API
API: a few paragraphs go here, long enough that the box scrolls and the list beside it follows the section in view.
More about api, with examples and notes.
FAQ
FAQ: a few paragraphs go here, long enough that the box scrolls and the list beside it follows the section in view.
More about faq, with examples and notes.
In view: Introduction
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/scrollspy.qweimport ui/theme.qwe use theme_cssuse ui/scrollspy.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
let scrollspy_demo_items = [ { id: "scrollspy-demo-intro", label: "Introduction" }, { id: "scrollspy-demo-setup", label: "Setup" }, { id: "scrollspy-demo-usage", label: "Usage" }, { id: "scrollspy-demo-api", label: "API" }, { id: "scrollspy-demo-faq", label: "FAQ" }]component ScrollspyDemoLive() { state at = "scrollspy-demo-intro" <div.demo-stack> <div style="display: flex; gap: 20px; align-items: flex-start"> <div style="flex: none; width: 150px"> <Scrollspy id="scrollspy-demo" items={scrollspy_demo_items} scroller="scrollspy-demo-article" offset={8} on_change={(id) => { at = id }}/> </div> <div id="scrollspy-demo-article" tabindex="0" aria-label="The guide" style="flex: 1; min-width: 0; height: 300px; overflow: auto; padding: 0 16px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius)"> {for it in scrollspy_demo_items => [ <h3 id={it.id} style="font-size: 16px; margin: 16px 0 6px">{it.label}</h3>, <p style="margin: 0 0 10px">{it.label}: a few paragraphs go here, long enough that the box scrolls and the list beside it follows the section in view.</p>, <p style="margin: 0 0 10px; color: var(--ui-muted)">More about {lower(it.label)}, with examples and notes.</p> ]} <div style="height: 140px"></div> </div> </div> <p.demo-note>In view: {find(scrollspy_demo_items, (it) => it.id == at).label}</p> </div>}view ScrollspyDemo() = <div.demo-stack style="gap: 24px"> <ScrollspyDemoLive/> <div style="border: 1px solid var(--ui-border); border-radius: var(--ui-radius); overflow: hidden"> <Scrollspy id="scrollspy-demo-row" horizontal={true} scroller="scrollspy-demo-article" items={scrollspy_demo_items} label="Sections, across"/> <p.demo-note style="padding: 10px 14px">The same sections in a row, following the same box.</p> </div> </div>Source
// Scrollspy: links to the sections of a page (or of a scrolling box) that mark the// one in view as it scrolls (aria-current="location"), down the side or in a row// across the top. A click scrolls there smoothly (at once for people who ask their// system for less motion), moves the focus to the section and puts its #id in the// address bar. Without JavaScript the links still go to the sections.//// <Scrollspy items={[{ id: "intro", label: "Introduction" }, { id: "setup", label: "Setup" }]}/>// <Scrollspy horizontal={true} sticky={true} scroller="article" offset={60} items={sections}/>//// items: { id (the section's id), label }, in the page's order. horizontal: a row// across instead of a list down. sticky: it stays in view as the page scrolls (top:// var(--ui-scrollspy-top, 0)). offset: how many pixels at the top are covered (a// fixed header): a section counts as reached, and a click stops, that far down (0).// scroller: the id of a scrolling element the sections are in, to follow instead of// the window. label: what screen readers call it ("Sections").// on_change(id): inside one of your components, called when the section in view changes.import ui/theme.qwe use ui_clslet scrollspy_css = css`.ui-scrollspy { min-width: 0; background: var(--ui-bg); font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-scrollspy-sticky { position: sticky; top: var(--ui-scrollspy-top, 0); z-index: 5; }.ui-scrollspy ul { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; border-left: 2px solid var(--ui-border); }.ui-scrollspy-link { display: block; margin-left: -2px; padding: 5px 12px; border-left: 2px solid transparent; color: var(--ui-muted); text-decoration: none; transition: color .15s, border-color .15s; border-radius: 0 var(--ui-radius-sm) var(--ui-radius-sm) 0;}.ui-scrollspy-link:hover { color: var(--ui-fg); }.ui-scrollspy-link:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-scrollspy-link[aria-current] { border-left-color: var(--ui-accent); color: var(--ui-accent); font-weight: 500; }.ui-scrollspy-horizontal ul { display: flex; gap: 4px; border-left: 0; border-bottom: 1px solid var(--ui-border); overflow-x: auto; scrollbar-width: none; }.ui-scrollspy-horizontal ul::-webkit-scrollbar { display: none; }.ui-scrollspy-horizontal .ui-scrollspy-link { margin: 0 0 -1px; padding: 10px 12px; border-left: 0; border-bottom: 2px solid transparent; border-radius: 0; white-space: nowrap;}.ui-scrollspy-horizontal .ui-scrollspy-link[aria-current] { border-bottom-color: var(--ui-accent); }@media (prefers-reduced-motion: reduce) { .ui-scrollspy-link { transition: none; } }`component Scrollspy(items: list = [], horizontal: bool = false, sticky: bool = false, offset: int = 0, scroller: str? = null, label: str = "Sections", id: str? = null, on_change = null, class: str? = null) { style global scrollspy_css state active = len(items) > 0 ? items[0].id : null state box = null state going = false let mark = (to: str?) => { if to != active { active = to on_change && on_change(to) } } // The last section whose top has passed `offset`, or the last of all at the very bottom. let spy = (_e = null) => { if !going && len(items) > 0 { let top = box != null ? box.getBoundingClientRect().top : 0 let bottom = box != null ? box.scrollTop + box.clientHeight >= box.scrollHeight - 2 : js("scrollY") + js("innerHeight") >= js("document.documentElement.scrollHeight") - 2 let passed = items | filter((it) => { let h = js("document.getElementById", it.id) h != null && h.getBoundingClientRect().top - top <= offset + 8 }) mark(bottom ? items[-1].id : len(passed) > 0 ? passed[-1].id : items[0].id) } } let done = (_e = null) => { going = false spy() } let jump = (e, to: str) => { // Found through the element, not js(): Quillo may wait on a js() call, and by // then the browser has followed the link, too late for preventDefault. let h = e.target.ownerDocument.getElementById(to) if h != null { e.preventDefault() let still = js("matchMedia", "(prefers-reduced-motion: reduce)").matches let top = box != null ? box.getBoundingClientRect().top : 0 let by = h.getBoundingClientRect().top - top - offset let at = box != null ? box.scrollTop : js("scrollY") // The focus and the address bar before the scroll: where scrollTo gives a // Promise, the handler waits for the scroll to end. if !h.hasAttribute("tabindex") && h.tabIndex < 0 { h.tabIndex = -1 } h.focus({ preventScroll: true }) js("history.replaceState", js("history.state"), "", `#${to}`) mark(to) going = by > 1 || by < -1 e.target.closest("a").scrollIntoView({ block: "nearest", inline: "nearest" }) (box ?? js("window")).scrollTo({ top: at + by, behavior: still ? "instant" : "smooth" }) } } <nav class={ui_cls(["ui-scrollspy", horizontal && "ui-scrollspy-horizontal", sticky && "ui-scrollspy-sticky", class])} id={id} aria-label={label} @mount={(el) => { box = scroller != null ? js("document.getElementById", scroller) : null let from = box ?? js("window") spy() from.addEventListener("scroll", spy, { passive: true }) from.addEventListener("scrollend", done) () => { from.removeEventListener("scroll", spy) from.removeEventListener("scrollend", done) } }}> <ul> {for it in items => <li key={it.id}> <a.ui-scrollspy-link href={`#${it.id}`} aria-current={it.id == active ? "location" : null} @click={(e) => { jump(e, it.id) }}>{it.label}</a> </li>} </ul> </nav>}Related
Thread 0 posts
On Scrollspy: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.