Navigation

Scrollspy

Links to the sections of a page that mark the one in view as it scrolls, down the side or in a row.

#navigation#link#page#interactive

Demo

Preview

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

The same sections in a row, following the same box.

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.qwe
main.qwe
import 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.

demos/scrollspy.qwe
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

ui/scrollspy.qwe
// 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>}
Thread
59Useful? Like it, or add to its thread below.

Thread 0 posts

On Scrollspy: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.