Skip to content

@glitchr/flowjs

Navigation without page loads (and a website in the website), sticky scroll effects, scroll stops, scroll-driven scenes, sortable lists, AJAX channels, cookies and consent - one library, one module per feature

Floor
Library
Version
master
Verified for real
Its README does not say
Licence
MIT
npm install @glitchr/flowjs

Scroll and navigation for pages that are server-rendered: links and forms that load the next page in the background, scroll effects, scroll stops, sortable lists, named AJAX channels, and the cookies with the visitor's consent. One library, one module per feature, one shared core. The modules are independent of each other: a page imports what it needs, and each module finds the others through the core when they are there (the stops hear the navigation's swaps, the sticky effects share the core's scroll listeners with the stops).

The pages stay the server's: no virtual DOM, no client-side routing table, no build step required. A module reads classes and data attributes the templates write, and announces what it does with DOM events a page listens to.

When to use it

  • A server-rendered site (Symfony, Laravel, any templates) that wants page changes without a full reload, with the history, the scroll positions, the head and the scripts kept in step.
  • Pages read by scrolling: a header that hides, sections a scroll settles on, the address that follows the headline being read, blocks that fade in.
  • A consent panel whose categories, cookies and features the site declares, in its own words.

When not: a single-page application with its own router and state (the navigation swaps a block of server HTML); a page that needs none of the modules - the core alone does nothing visible.

Modules

Module Global Entry What it does Needs Manual
core Flow loaded by every entry units and durations, scrollers, one scroll/resize/interrupt listener per scroller, one animation frame for the measurements, programmatic scroll, the address hash, page-loaded moments, the jQuery plugins nothing Flow (core)
navigation Transparent, FlowFrame navigation links and forms load in the background and swap the page in place; history, scroll positions, head and scripts kept in step; a progress bar that never blinks; a website in the website (1.6); nested panels, prefetch, form memory, lazy loading jQuery (FlowFrame: nothing) Transparent
sticky Sticky sticky scroll effects: hiding header, bottom bar, widgets, fading blocks, the address following the headline read, magnets, scroll-percent triggers, swipes, autoscroll jQuery Sticky and stops
stops StickyStops stops places on the page that a scroll settles on once it comes to rest, with keys and sideways rows nothing Scroll stops
scenes StickyScenes scenes sections the scroll drives while their stage stays pinned: a progress 0 to 1 in CSS and in an event, steps, an image sequence (1.6) nothing Scroll-driven scenes
sortable StickySortable sortable drag-and-drop reordering with pointer events nothing (jQuery only for the $.fn.stickySortable plugin) StickySortable
ajaxer Ajaxer ajaxer named AJAX channels over $.ajax: throttle, debounce, loader, retry, request limit jQuery Ajaxer
cookie Cookie cookie what a site stores in the browser and the visitor's consent to it: categories, the cookies and storage entries under each, features started at a grant and stopped at a withdrawal, a banner and a detailed section in the site's words nothing Cookie

Each module sets its global (window.Transparent, ...) and registers itself on the core (Flow.navigation, Flow.sticky, Flow.stops, Flow.sortable, Flow.ajaxer, Flow.cookie). A module that needs jQuery and does not find it throws when loaded ("sticky.js: jQuery is needed"); one loaded without the core throws "... load core.js first".

Installation

With a bundler

npm install @glitchr/flowjs

Everything, with the stylesheets (a bundler with Sass, and jQuery provided - e.g. webpack's ProvidePlugin, which gives the bare jQuery identifier the modules look for first):

import '@glitchr/flowjs';

One module (its stylesheet when it has one, and the core):

import '@glitchr/flowjs/navigation';   // Transparent, src/css/navigation.scss
import '@glitchr/flowjs/sticky';       // Sticky, src/css/sticky.scss
import '@glitchr/flowjs/stops';        // StickyStops (no stylesheet)
import '@glitchr/flowjs/sortable';     // StickySortable (no stylesheet)
import '@glitchr/flowjs/ajaxer';       // Ajaxer, src/css/ajaxer.scss
import '@glitchr/flowjs/cookie';       // Cookie (no stylesheet imported)

The stylesheets are src/css/navigation.scss, sticky.scss, ajaxer.scss, and index.scss for all three. cookie.scss, a minimal look for the cookie banner, is imported by no entry - a site asks for it (@glitchr/flowjs/src/css/cookie.scss) or styles its banner itself.

A webpack Encore site, for instance:

// webpack.config.js
Encore.autoProvidejQuery();            // jQuery for the navigation, sticky and ajaxer
// assets/app.js
import '@glitchr/flowjs/navigation';
import '@glitchr/flowjs/stops';
Transparent.ready({ identifier: '#page' });

Without a bundler

The files of src/js/ in a page, core.js first, after jQuery for the modules that need it:

<script src="jquery.js"></script>
<script src="src/js/core.js"></script>
<script src="src/js/stops.js"></script>

The modules are plain scripts (UMD-style: they set their global, and module.exports under CommonJS). The stylesheets are Sass: compile src/css/*.scss to CSS for such a page.

How this manual is organised

  • Flow (core): what every module shares - units, durations, scrollers, listeners, the frame, programmatic scroll, the hash, the page's moments, the jQuery plugins.
  • Transparent (navigation): the navigation, its options, events, cache, prefetch, form memory, lazy loading and nested panels.
  • Sticky and stops: the scroll effects (options, classes, events, methods), then the scroll stops.
  • Scroll-driven scenes: a pinned stage the scroll plays (1.6).
  • StickySortable, Ajaxer, Cookie: one page each, with its options table, attributes, events and methods.
  • Testing: the test pages, how to run them, what each covers.

Headings that end with a version, "(1.1)", name the version that brought what they describe.

Changelog and versions

CHANGELOG.md says what each version changed; this manual describes 1.5.4 (package.json). Each module also carries a number of its own, older than the package's (Transparent.version, Sticky.version, Ajaxer.version, Flow.version): read the version from package.json.

The website, https://glitchr-studio.github.io/flowjs/, runs every module live beside its code and holds this manual on one page with the API reference and the changelog.

Licence

MIT since 2026-10-10; earlier versions remain published under LGPL-3.0-or-later.

Propose a change docs/index.md

Flow (core)

src/js/core.js - what every module shares, once: the units and durations, the scrollers, one native listener per scroller and per kind, the one animation frame in which the modules measure, the programmatic scroll, the address's hash, the moments a page is (re)loaded, and the jQuery plugins. It needs nothing; it uses jQuery when there is one.

Every entry point loads it first. Under a bundler a module asks for it as require('./core.js'); in a page it is loaded by a <script> before the modules, which find it as window.Flow:

<script src="jquery.js"></script>          <!-- for the modules that need it -->
<script src="src/js/core.js"></script>
<script src="src/js/stops.js"></script>

Loaded twice (two bundles of one page), the second copy finds window.Flow (Flow.core === true) and hands it back: there is one core per page, one set of listeners, one frame. Each module registers itself on it: Flow.navigation, Flow.sticky, Flow.stops, Flow.sortable, Flow.ajaxer, Flow.cookie. Flow.version is the package's version, as package.json says (tests/units-and-listeners.html checks the two agree).

jQuery

Flow.jquery() returns jQuery, or null when the page has none. It looks for it when first asked (the bare jQuery identifier a bundler's ProvidePlugin gives, else window.jQuery), so a jQuery loaded after core.js is still found. The first time it finds one it installs the jQuery plugins, once.

What changes with jQuery and without:

With jQuery Without
Flow.scrollTo with a duration or a speed animated ($.fn.animate, the easing named) at once, whatever the duration
the user-scroll flag a jQuery property ($(el).prop('user-scroll')) a property of the element
the plugins installed none

Time and durations

Function Returns
Flow.now() performance.now() (or Date.now() without it), in ms
Flow.parseDuration(value) a duration in seconds: "250ms" gives 0.25, "1s" 1, "0.3" 0.3 (a bare number is seconds). Several separated by a comma and a space ("1s, 250ms", as getComputedStyle writes transition durations): the longest
Flow.debounce(fn, wait, immediate) a function that calls fn once wait ms have passed without a call; with immediate, at the first call and not after

Every duration option of the modules ("250ms", "2s") is read with parseDuration.

Units

Function Returns
Flow.parseToPixel(value, el) px from "90", "90px", "2rem" (the root's font size), "1.5em" (the font size of el's parent), "15%"; several separated by a comma and a space: the largest. undefined gives undefined
Flow.toPixels(value, el, fallback) one value to px: "90", "90px", "2rem", "1.5em" (the font size of el itself); empty or unreadable gives fallback. A % is not read: "15%" gives 15
Flow.remToPixel(rem), Flow.emToPixel(em, el), Flow.percentToPixel(p, el) the three conversions parseToPixel uses; a percentage is a hundredth of el's width (the window's without one): '15%' of 400 px is 60
Flow.scrollPadding(el) the scroll-padding of an element (the <html> element by default, also for window and document), {top, left, right, bottom} in px, each side read with parseToPixel (0 when unreadable)
Flow.viewport() the viewport's {width, height}: the larger of the root's client size and the window's inner size, read once per frame

Scrollers

A scroller is the window (the page) or an element whose content overflows and whose overflow scrolls (scroll or auto). Everywhere an element is taken, a jQuery object is accepted too (its first element is meant).

Function Returns
Flow.isScrollableNode(node) whether this one node scrolls: content larger than its box on an axis whose overflow is scroll or auto; the <html> element as soon as its content is larger (window and document mean it)
Flow.closestScrollable(el) the element itself when it scrolls, else its closest ancestor that does, else <html> when the page scrolls, else undefined - also for an element no longer in the page
Flow.normalize(scroller) window for nothing, document, <html> or <body>; the element otherwise: the key the listeners are kept under
Flow.element(scroller) the element to read scrollTop from and to scroll: <html> for the window

Listeners

One native listener per scroller and per kind, however many modules listen. Scroll and resize callbacks run together in the frame, after the browser has laid the page out, with the last event of the frame; an interruption by the reader is told at once, so that a glide stops under their finger.

Function Calls
Flow.onScroll(scroller, fn) fn(event, scroller) once per frame in which the scroller scrolled (scroll, passive). The window for the page. Returns fn
Flow.offScroll(scroller, fn) stops; the native listener goes with the last callback
Flow.onInterrupt(scroller, fn) fn(event, scroller) at once when the reader takes over on the scroller: wheel, touchstart or mousedown (Flow.INTERRUPT), passive
Flow.offInterrupt(scroller, fn) stops
Flow.onResize(fn) fn(event) once per frame in which the window was resized (resize, orientationchange)
Flow.offResize(fn) stops

A function added twice to the same scroller is called once. At each transparent:load the listeners of a scroller that left the page with the page the navigation swapped out are removed: kept, each page visited would stay in memory through them.

var onScroll = Flow.onScroll(window, function (event, scroller) {
    var top = Flow.element(scroller).scrollTop;      // read in the frame, after layout
    progress.style.width = 100 * top / (document.documentElement.scrollHeight - innerHeight) + '%';
});
// later
Flow.offScroll(window, onScroll);

The frame

Flow.frame(fn) queues fn(time) for the next animation frame and returns it. Every function queued runs in that one requestAnimationFrame, each once however often it was queued, in the order queued. Queued from inside the frame (a scroll heard there queues what it draws), it runs in the same frame after the others, unless it has run in this frame already: then it runs in the next. An exception thrown by one does not stop the others (it is thrown again outside the frame). Flow.cancelFrame(fn) takes it out of the queue.

Run in headless Chrome: Flow.frame(a); Flow.frame(a); Flow.frame(() => { b(); Flow.frame(c); }) calls a, b, c, in that order, in one frame.

Programmatic scroll

The modules tell a scroll of their own from the reader's by two marks:

  • the user-scroll property of the scroller, false while a script moves it;
  • Flow.scripted(scroller), true while a script moves it, for 250 ms after, and as long as the scroller stays where a script left it without a scroll listener having heard it there (after a swap, the frame carrying that scroll event can come late).
Function Effect
Flow.userScroll(el) whether the reader has the scroll of el's scroller (<html> by default): true unless a script set user-scroll to false; true too when nothing scrolls
Flow.scripted(scroller) see above
Flow.jump(scroller, top, left) puts the scroller there at once (behavior: 'instant', whatever the page's scroll-behavior), marked as a script's move. null or undefined leaves an axis as it is
Flow.scrollTo(scroller, options, callback) scrolls, see below
Flow.release(event, scroller) gives the scroll back to the reader: stops jQuery's animation of the scroller, sets user-scroll to true, ends its movement as interrupted (unless the movement holds)

Flow.scrollTo

Flow.scrollTo(window, { top: 1200, duration: '400ms', easing: 'swing' }, function (end) {
    // end: {completed, reason, top, left}
    if (end.reason === 'interrupted') console.log('the reader took over at', end.top);
});
Option Type Default Effect
top, left number (px) where the scroller is the place to go to, clamped to what the scroller can reach
duration duration 0 the time of the movement ("250ms", "1s"); 0 or no jQuery: at once
duration-x, duration-y duration duration the time along each axis: the movement goes down first, then across, each only when there is a way to go
speed number (px/s) 0 instead of a duration: each axis takes its way to go divided by the speed, up as down (from 3000 to 2000 at 1000 px/s: a second)
easing string 'swing' a jQuery easing (swing, linear, or one a plugin adds)
debounce number (ms) 0 the end (the callback, user-scroll given back) comes that many ms after the movement stopped
hold boolean false the reader's wheel, finger or press does not stop the movement

The callback is called once, however the movement ends, with {completed, reason, top, left}:

reason completed When
arrived true the scroller is there (also when there was no way to go); a scroll event (not bubbling) is dispatched on the element scrolled - <html> for the page
interrupted false the reader took over (a wheel, a finger, a press)
replaced false another Flow.scrollTo of the same scroller began

The reader's wheel, finger or press stops a movement (unless hold): on the page through the core's one interrupt listener on window, and on an element scroller through a listener the movement adds to that element for its own time (Flow.onInterrupt(el, ...), taken off when it ends) - a script does not need to register Flow.onInterrupt(el, Flow.release) for it.

While it moves the scroller's user-scroll is false; it is true again at the end unless the callback began another movement. An animation something else stopped (a site's own .stop(), frames that never came) is not waited for: a second past its time, the scroller is put where it was going and the movement ends arrived.

The hash

Flow.replaceHash(hash, triggerHashChange = true, skipIfEmptyIdentifier = true) changes the address's #hash without a history entry (history.replaceState, keeping the query string and the history state, href set to the new address). It returns true when the address changed. window.replaceHash is the same function.

  • hash with or without its #.
  • No element with that id, or the new element contains the current hash's element (going "up" to a section that holds where the reader is): hashfallback (a HashChangeEvent, with oldURL and newURL) is dispatched on window, and the hash is emptied (no element) or left as it is (the containing section).
  • An empty hash with skipIfEmptyIdentifier: hashfallback, and the hash is emptied.
  • When the address changed and triggerHashChange is true: hashchange (a HashChangeEvent) is dispatched on window.
  • Inside a srcdoc frame (a nested panel of the navigation), where the state may not be replaced, the address stays and the events are still dispatched.

Run in headless Chrome on a page with an #anchor element: replaceHash('anchor') true, the hash #anchor, one hashchange; replaceHash('missing') true, the hash emptied, hashfallback then hashchange; replaceHash('') false, hashfallback.

The page's moments

Flow.onPage(fn) calls fn(moment) at each moment a page is in, and returns fn:

moment When
'ready' at once, when onPage is called after the document was parsed
'DOMContentLoaded' the document parsed
'load' the page loaded
'transparent:load' each page the navigation swapped in place

A module that needs the page's elements attaches there, and is attached again after every navigation without knowing there was one. Flow.offPage(fn) stops. Registered in the <head>, a function hears DOMContentLoaded then load; registered after the page loaded, ready only (then each transparent:load).

Flow.onPage(function (moment) {
    document.querySelectorAll('.gallery:not(.is-ready)').forEach(startGallery);
});

jQuery plugins

Installed once, the first time Flow.jquery() finds jQuery (each module asks):

Plugin Effect
$.event.special.touchstart, touchmove jQuery's listeners for them are passive, unless the event's namespace contains noPreventDefault (.on('touchmove.noPreventDefault', ...): then a handler may call preventDefault())
$.event.special.wheel, mousewheel always passive
$(form).serializeObject() the fields as an object; a name given several times becomes an array
$(el).isScrollable() whether one of the elements scrolls (Flow.isScrollableNode)
$(el).isScrollableX(), isScrollableY() a jQuery set of booleans, one per element, for one axis
$(el).closestScrollable() the set of Flow.closestScrollable of each element
$(el).closestScrollableWindow() the same, window in place of <html>
$(el).repaint(duration = 1000, reiteration = 5) forces the elements to be laid out again, now and reiteration times over duration ms (a browser that leaves a stale paint)

The special events change how every jQuery listener of the page for these events is added, the site's included: a site's $(el).on('touchmove', fn) that calls preventDefault() needs the noPreventDefault namespace.

Propose a change docs/core.md

src/js/transparent.js - links and forms load the next page in the background and the page's block (identifier, #page by default) is swapped in place, with the history, the scroll positions, the head and the scripts kept in step. Needs jQuery and the core.

import '@glitchr/flowjs/navigation';

Transparent.ready({ identifier: '#page', prefetch: true, nest: ['/admin/*'] });

After each swap the page announces transparent:load (the stops, the sticky effects and the other modules attach themselves again to the new page through the core). A link with rel="prefetch" is fetched when the pointer settles on it.

@glitchr/flowjs/navigation loads the core, transparent.js and src/css/navigation.scss. Without a bundler, jQuery, src/js/core.js and src/js/transparent.js in that order (a page without jQuery gets transparent.js: jQuery is needed; one without the core transparent.js: load core.js first). The script sets window.Transparent and Flow.navigation.

Loading the script already does some of the work: it puts transparent loading first active on <html>, replaces the current history entry with one of its own ({uuid, status, method, data, href}), listens to clicks, submissions, Back/Forward and hash changes, starts the form memory, the lazy loading of pictures and the hover prefetch, and gives a reloaded page its scroll position back. Nothing is swapped yet: until Transparent.ready() is called, a click on a link is left to the browser (a full page load) and <html> keeps loading first.

<!-- the page as the server renders it -->
<html>
<head>...</head>
<body>
    <div id="page" data-layout="article">...</div>
    <div id="loader"></div>
    <script src="/js/jquery.js"></script>
    <script src="/js/core.js"></script>
    <script src="/js/transparent.js"></script>
    <script>Transparent.ready({ identifier: '#page', exceptions: ['/download/*'] });</script>
</body>
</html>

Transparent.ready(options):

  • sets the options (configure()), and the internal x-ajax-request setting to true;
  • the first time only: checks cache_version and dispatches transparent:first, and starts checking cache_version again each time the tab becomes visible;
  • each time: dispatches transparent:ready, adds ready to <html>, notes the page's layout, starts the lazy loading again;
  • while <html> has first: scrolls to the address's hash (not after a reload, which already put the page back where it was) and then plays the leaving phases (active-out), which end with <html> at transparent ready.

It returns Transparent. Transparent.configure(options) only sets the options (and the progress-bar-native class, see progress_bar); it also returns Transparent.

Two ways to turn the navigation off:

  • class="disable" on the server's <html> when the script loads: no listener is added at all, <html> gets transparent ready disable, every link and form is the browser's.
  • Transparent.ready({ disable: true }) (or configure): the listeners stay, and give up at once, so every click and submission is the browser's; the nest does not open either.

An answer whose <html> carries reload or disable is not swapped in: the page is loaded by the browser at its address (see The answer).

Set with Transparent.ready({...}) or Transparent.configure({...}).

Option Default Meaning
identifier "#page" the block swapped
loader "#loader"
throttle 1000
rescue_reload 5000 ms before the page reloads after a failure that came with an empty page
lazyload true the lazy loading of img[data-src]; false switches it off
exceptions [] paths (RegExp or wildcard strings) left to the browser
prefetch true only anchors with rel="prefetch" are fetched ahead; false switches the mechanism off
prefetch_delay 65 ms the pointer dwells before the fetch
prefetch_ttl 30000 ms a prefetched page stays usable
prefetch_max 15
exit_duration 0 ms html.exiting is held after the response arrives, before the swap (plain milliseconds)
smoothscroll_duration / _speed / _easing "200ms" / 0 / "swing"
headlock [] URL substrings or patterns kept in <head> across pages; data-headlock="true" / "false" on an element overrides
use_view_transitions false wrap the swap in document.startViewTransition() where supported
skip_transition_for_cache false a page given back from the history's cache (Back, Forward) skips the view transition
progress_bar true the native bar (below); false or 'off' for another indicator
progress_bar_delay 200 ms a navigation must last before the bar shows: a faster one shows none (1.6)
progress_bar_min 400 ms the bar stays once shown (1.6)
progress_bar_finish 250 ms the finished bar takes to fill and fade (1.6)
cache_version null a value changing at each deploy purges the response cache on mismatch
nest [] path patterns opened as a nested website-in-website panel; the target must send the X-Transparent-Nest header
nest_move, nest_resize, nest_snap true drag the chrome bar, resize by the edges, magnetic snapping
nest_snap_threshold 8 px
nest_dock true docking against an edge; nest_dock_target (null) names an element to dock into
nest_swipe true small screens: drag the bar down to dismiss
nest_keepalive true closing parks the nested session, the next open resumes it
nest_remember true the panel's layout is kept in localStorage under nest_remember_key ("transparent[nest][layout]")
nest_esc_confirm "Close this panel? Any unsaved changes may be lost." confirm before ESC closes a nest
disable, debug false

The comments above each option in src/js/transparent.js give the reasoning.

The settings object in the order the source declares it, with what the code does with each.

Option Type Default Effect
headers object {} extra request headers of every navigation request (jQuery adds X-Requested-With: XMLHttpRequest to it); not sent by the prefetch nor by the nest
data object {} data kept by history entry (uuid -> object), read and written by getData(uuid) / setData(uuid, data); what a hash change carries
disable boolean false every listener gives up at once: the browser handles the links and forms, the nest does not open, ESC does nothing
global_code boolean true run the <script> elements inside the swapped block; false: none of them runs (neither inline nor with src)
debug boolean false says in the console what it does (running, a failed pushState, a track-reload, a view transition that failed, the nest's failures); with debug, a JSON answer that does not parse is rescued
lazyload boolean true the lazy loading (see Lazy loading); false: none of its runs - at DOMContentLoaded, at ready(), at each swap, a call of Transparent.lazyLoad() - gives a picture its src, and the pictures already watched keep their data-src when they come into view
response_text object {} declared, never read
response_limit number 25 how many pages the sessionStorage cache keeps (the oldest goes first)
throttle number (ms) 1000 the wait before a full page load when Back or Forward lands on an entry the cache does not have; nothing else
rescue_reload duration 5000 a failure that came with an empty page (no head and no body, or both empty - a 500 with nothing in it): the block reads <div class='error'></div> and the page reloads this long after (a number of ms, "5s", "300ms"); see A page under another status than 200
prefetch boolean true false turns the hover prefetch off; true prefetches only the links marked rel="prefetch" (see Prefetch)
prefetch_delay number (ms) 65 how long the pointer rests on a link before its prefetch starts; a touch starts it at once
prefetch_ttl number (ms) 30000 how long a prefetched page answers a click; 0: no limit
prefetch_max number 15 how many prefetched pages are kept (the oldest goes first)
exit_duration number (ms) 0 how long exiting stays on <html> between the answer and the swap, for an exit animation; plain milliseconds, never a string with a unit
identifier selector "#page" the block swapped; a page without it is neither swapped in nor taken from (a click on a page that lacks it is the browser's)
loader selector "#loader" the element whose CSS transition and animation times (its own, ::before, ::after) set the length of each phase; <html> when nothing matches. Read again at each DOMContentLoaded, the ones the navigation dispatches included
smoothscroll_duration duration "200ms" the scroll to an anchor of the page, and to the hash of the first page
smoothscroll_speed number (px/s) 0 when set, the anchor scroll's duration follows from the distance instead
smoothscroll_easing jQuery easing name "swing" the anchor scroll's easing, when jQuery animates it
exceptions array [] RegExp or wildcard strings (* matches anything), tested against the path only (no query, no hash): matching links and forms are the browser's; never prefetched
nest array [] the same kind of patterns: links whose path matches open in the nest (see The nest)
nest_move boolean true the panel is moved by its chrome bar
nest_resize boolean true the panel is resized by its edges and corners
nest_snap boolean true a resize snaps flush to the viewport's edge within nest_snap_threshold; false also stops a drag past an edge from docking there
nest_snap_threshold number (px) 8 how close counts as flush for a resize, how far past the viewport's edge a drag must go to dock against it
nest_dock boolean true false forbids docking of every kind (the dock button, a move or a resize away from the centre, a drag to an edge, nest_dock_target)
nest_dock_target selector or null null an element of the host page the dock button docks the panel into (its own children are put aside and given back on undock); null: the dock button floats the panel
nest_swipe boolean true on a screen of 768 px or less, dragging the chrome bar down (120 px, or 0.5 px/ms) closes the panel
nest_keepalive boolean true a closed panel is parked (hidden, its page still alive) and comes back on the next open of the same page; false: closed is destroyed
nest_remember boolean true where the panel was put, its size, its dock and its edge are kept in localStorage and given back at the next open (not full page, not tucked away, never on a screen of 768 px or less)
nest_remember_key string "transparent[nest][layout]" the localStorage key of that layout
headlock array [] strings (contained in) or RegExp (tested on) the src or href of a head element, or the text of an inline <style>: such elements stay in the head across swaps (see The head and the scripts)
use_view_transitions boolean false the swap runs inside document.startViewTransition() where the browser has it; elsewhere, and if it throws, the swap runs as usual
skip_transition_for_cache boolean false with use_view_transitions, a page given back from the history's cache (Back, Forward) is swapped without the view transition; a page from the network (a link, a form, a prefetched page) still goes through it
progress_bar boolean or "off" true false or "off" takes progress-bar-native off <html>, which hides the bar and the progress cursor of navigation.scss
progress_bar_delay number (ms) 200 a navigation shorter than this shows no bar at all (1.6, see The progress bar)
progress_bar_min number (ms) 400 once shown, the bar stays at least this long (1.6)
progress_bar_finish number (ms) 250 the finished bar fills and fades over this (1.6)
nest_esc_confirm string "Close this panel? Any unsaved changes may be lost." the confirm() question before Escape closes the nest
cache_version any or null null compared, as a string, with the one kept in sessionStorage at the first ready(), at each navigation and each time the tab becomes visible: different, every cached page is dropped. null: never checked

A click on an <a href> (or on anything inside one) is a navigation of the page when all of these hold; otherwise the browser follows the link as usual:

  • Transparent.ready() was called, and disable is off;
  • nothing called preventDefault() on the click before (an inline onclick returning false counts);
  • the current page has the identifier block;
  • the path matches none of exceptions and does not start with /css, /js, /images or /vendor;
  • the link is of this origin;
  • no modifier key is held (Cmd, Ctrl, Shift, Alt) and the link has no target="_blank";
  • the link has no download attribute (the browser saves its file, under the name the attribute gives; the prefetch leaves such a link alone too).

A link to another page of the site is then fetched and swapped in. A link to the same address and query with a hash scrolls to that anchor (see Anchors of the page); the very same address with no hash does nothing.

class="reload" lets the browser have a form (see below); on a link it changes nothing: the link is still swapped in (checked in headless Chrome). An excepted path is the way to have one link loaded by the browser.

A submission is taken the same way: a click on a type="submit" button or input (its form found through its form attribute, its ancestor, or a form named like it), and every submit event that reaches the document (Enter in a field, requestSubmit(), a <button> that is a submit button by default). A submit listener of the site that calls preventDefault() keeps the form from being sent.

  • Validation: a form the browser finds invalid is not sent. Invalid form submission. and each invalid control as name: message are said in the console, the form gets was-validated, and the first invalid control shows the browser's own message (reportValidity()), focused and in view. class="skip-validation" on the button that sends it skips the check.
  • Where and how: the button's formaction and formmethod first, then the form's action and method; a form without action goes to its own page, query included. A submission by submit event defaults to GET, a click on a submit button to POST, when nothing names a method.
  • What is sent: what a browser sends - named, enabled controls only; a checkbox or radio only when ticked; of the buttons only the one pressed (a click on its icon counts); each file of a file input; each selected option of a multiple select; the controls outside the form bound to it by form="its-id". A form that shares its name with no other also takes every control of the page whose name starts with name[.
  • GET: the fields become the address's query (a file is left out), so the address can be reloaded and shared.
  • One at a time: while a form's request is out, its submit buttons are disabled, and a second submission of the same form is ignored; any answer frees it.
  • class="reload" on the form or on the button pressed: the browser submits the form itself.

The navigation dispatches its own submit event on the form (cancelable, not bubbling) before sending, so the site's submit listeners and the form memory see every submission; the browser's own submission of that event is cancelled (Firefox would send it a second time).

After a POST answered under 400 every cached page is dropped (invalidateResponses()): Back must not give back a form as it was before the write.

A link to an anchor of the current page (same path and query) scrolls there smoothly (smoothscroll_duration, _speed, _easing), the target under the core's scroll padding (see the core), in the nearest scrolling ancestor of the link; on arrival the hash is written in the address, unless the link has data-skip-hash="true" or the reader took the scroll on the way. Changing the hash by hand (location.hash = '#x') scrolls the same way.

A navigation request is a jQuery $.ajax call (X-Requested-With: XMLHttpRequest, plus the headers option), with the method found, the fields as a FormData for a POST. A newer navigation aborts the one in flight: the reader's last click wins, and a late answer of an older one is discarded. A GET already prefetched answers at once; one whose prefetch is still on the wire waits for that request instead of sending a second one.

What the answer is decides what happens:

Answer What happens
a page with the identifier block, any status swapped in, at the address the request ended at (redirects followed), with a history entry {uuid, status, method, data: {}, href}
status 0, nothing received the page stays: see A request that gets no answer
Content-Type: application/json (exactly) not a page: the form that was sent is reset and its buttons enabled; {"code": "302", "target": url} sends the browser to target (or reloads); load is dispatched on window
Content-Type: text/html (exactly) without <html, <head> or <body in it rescued (below)
status 302 as seen by the request the browser goes to the answer's address
a status other than 200 without the block rescued: see A page under another status than 200
a page without the block the browser loads the address itself (a POST's fields are not sent again)
a page whose block has another data-name than the current one (default when none) the browser loads the address itself; a POST that sent fields is the exception, swapped in
<html> with reload or disable the browser reloads the address
a head whose data-track="reload" elements differ from the current ones the browser loads the address (an asset bundle changed: a deploy)

data-layout on the block names its layout: <html> gets new the first time a layout is seen in the tab, same when the next page has the current layout, and <previous>-to-<next> (for example article-to-home, with the event transparent:article-to-home); the incoming block gets data-layout-prev with the layout it replaces. The <previous>-to-<next> class is on <html> from the answer to the end of the entrance: the swap spares it (it takes off the other classes the new page's <html> does not carry) and it goes once the page is in, with new, submit and popstate. CSS can animate an entrance by layout pair on it.

  • The attributes of <html> (all but class), <head> and <body> become the new page's.

  • Head: an element of the current head that the new head has no equal of (isEqualNode) is removed, unless it is headlocked; an element of the new head that the current head has no equal of is added. A headlocked element: data-headlock with any value but "false"; anything added to the head after the script loaded (a widget's style, an analytics tag); or a match of the headlock option. data-headlock="false" makes it removable again.

  • Scripts of the head and of <body>'s children: a new <script> is created in the live document (its attributes copied, async = false to keep their order), so it runs as it would in a page load; a stylesheet <link> added is waited for too. The load of the swap waits until they have loaded or failed (10 s at most). A body > script the current body already has (equal node) does not run again.

  • Scripts inside the block: with global_code on, each <script> of a JavaScript type (no type, a JavaScript MIME type or module; never application/json, ld+json or a template) is replaced by a fresh copy, which the browser runs once, as on a page load: a var of its own is a global, a module runs as a module, document.currentScript is the copy. A script that throws is the browser's uncaught error, as any page script's, and the swap goes on.

  • Canvases: a <canvas> with an id that the new block has too is moved into it, drawn as it was; one without an id is kept where the new page has the same place for it.

The new block is inserted before the old one, which is removed. <html> takes the classes of the new page's <html>, and loses those it does not carry, except the state classes (below), the classes of Transparent.persistentClasses (a Set: progress-bar-native, nested, nest-docked, nest-loading; add a site's own runtime class to it) and the layout-switch class.

A frame after the swap (once the browser has laid the page out):

  • a page reached by a link or a GET form starts at its top, or at its hash's element when the address has one;
  • a page given back by Back or Forward gets the positions it was left with - the window's and every scrolling element's - and the window's position is held while the page grows (see Back, Forward, and a page that is not whole yet);
  • a POST that comes back to the address it was sent from (a form the server refused) keeps the reader where they were;
  • a POST that lands elsewhere is not scrolled by the navigation.

Once the scripts the swap brought have run: DOMContentLoaded on document and on window, then the leaving phases start, then transparent:load and load on window - the order of a page load, so a site's own start-up code runs again for the new page.

Class When
transparent always, once the script is loaded
first from the load of the document until the first ready() has played its leaving phases
ready after ready()
loading from the click until the new page is in and its phases are over
pre-active, active-in, active, active-out, post-active the phases (below)
exiting between the answer and the swap, for exit_duration ms (only when it is above 0)
new the incoming page's layout is the first of its kind in this tab
same the incoming page has the current layout
<previous>-to-<next> the two layouts, from the answer on (see the defect above)
submit, popstate the navigation came from a form, from Back/Forward
disable the navigation is off (see Starting it)
progress-bar-native progress_bar on
nested, nest-docked, nest-loading the nest (see The nest)

The phases and their timing: at the click pre-active, at once replaced by active-in; after the loader's delay active (active-in removed); the answer arrives, waits for what is left of the loader's delay and duration, then the swap; then active-out, after the loader's delay active goes, after its duration active-out goes and, loading being set, every state class goes and <html> is transparent ready again; post-active then stays for the loader's delay and duration. The loader's times are the largest transition-delay/animation-delay and transition-duration/animation-duration of the loader element and of its ::before and ::after, read when each phase starts: a curtain whose transition lasts 300 ms holds each phase 300 ms. With no CSS time at all, the phases follow each other within the same task.

#loader { position: fixed; inset: 0; background: #fff; opacity: 0; pointer-events: none; transition: opacity .25s; }
html.loading.active #loader { opacity: 1; }            // the curtain is down while the page changes
html.exiting #page { transform: translateY(-1rem); opacity: 0; transition: all .12s; }   // with exit_duration: 120

With progress_bar on, navigation.scss draws a bar at the top of the window and a progress cursor while html.progress-bar-shown (since 1.6: only for a navigation longer than progress_bar_delay, at least progress_bar_min, then html.progress-bar-done while it fills and fades - never on the first page load; see The progress bar).

Dispatched on window (none bubbles from an element); the transparent:* ones are plain Events unless a detail is given.

Event When detail
transparent:active when the script loads (<html> gets active), and when a phase adds active
transparent:first the first ready()
transparent:ready every ready()
transparent:link a click, submission, Back/Forward or hash change that names a destination - before any check, so also for a link the browser then follows (another origin, an exception) {method, url, target}: the method, a URL, and the link, button or form (for Back/Forward the entry's data)
transparent:beforeunload the page is about to be replaced (and kept in the cache); followed by a beforeunload (cancelable, not obeyed)
transparent:pre-active, transparent:active-in the phases of the click
transparent:exiting the answer is in, exiting is set (only with exit_duration)
transparent:new the incoming layout is new to the tab
transparent:popstate, transparent:submit the navigation came from Back/Forward, from a form
transparent:<previous>-to-<next> the two layouts of the swap, every swap (transparent:article-to-article included)
DOMContentLoaded on document and window, after the swap and its scripts
transparent:active-out the leaving phase starts
transparent:loading the phases are over and loading is taken off (despite its name: the end)
transparent:load, then load the new page is in
transparent:post-active post-active taken off
transparent:nest:* the nest: see Events of the nest
addEventListener('transparent:link', (e) => {
    if (e.detail.target instanceof HTMLFormElement) console.log('sent', e.detail.method, e.detail.url.pathname);
});
addEventListener('transparent:beforeunload', () => myWidget.destroy());   // before the page is kept
addEventListener('transparent:load', () => myWidget.mount(document.querySelector('#page')));

What the script listens to: click on document; submit on document (jQuery delegation); window.onpopstate, window.onhashchange and window.onbeforeunload, set as properties (a site that assigns them afterwards replaces the navigation's own); mouseover, mouseout and touchstart (capture) for the prefetch; input, change and submit (capture) for the form memory; DOMContentLoaded (the loader, the lazy loading, the form memory); visibilitychange (cache_version); pagehide (the reload position, the nest's layout); keydown (capture: Escape in the nest).

A page left by a link, by Back or by Forward is kept as it is (its DOM in memory for five minutes, its HTML in sessionStorage), with the scroll position of the window and of every element that scrolls. Coming back, it is given its place again - and that place is held while the page finishes growing (sections a controller sizes, pictures without dimensions): put back at each change of size, the browser's scroll anchoring off meanwhile, until the page has been quiet for 1.5 s (6 s at most). The reader taking the scroll (a wheel, a finger, a press, a key), a script of the page scrolling it, or the next navigation ends the hold at once.

The kept page includes what scripts added to it. transparent:beforeunload is dispatched before a page is kept: the moment to take away what should not come back with it.

Where What Bound
memory (Transparent._liveDomCache) a copy of the whole <html> of each page left, with its scroll positions 25 pages (Transparent.liveDomCacheMax), 5 minutes each (Transparent.liveDomCacheTTL, ms)
sessionStorage["transparent"] the list of the cached entries' uuids response_limit (25)
sessionStorage["transparent[response][<uuid>]"] the page's HTML
sessionStorage["transparent[position][<uuid>]"] the [top, left] of every scrolling element, in document order
sessionStorage["transparent[window][<uuid>]"] the window's own [top, left]
sessionStorage["transparent[version]"] the last cache_version seen

Back or Forward to an entry the cache no longer has (another tab session, an evicted page) loads the page through the browser after throttle ms. A full sessionStorage drops the oldest half of the cached pages and tries again; a page too large to fit at all clears sessionStorage. Transparent.invalidateResponses() drops every cached page (it runs after each POST answered under 400); a cache_version that changed does the same.

Outside the nest, the window's position is written at pagehide in sessionStorage["transparent[reload]"] as {href, top, left}, and a reload of the same address puts it back - at once, at DOMContentLoaded, at load and once more after - until the visitor scrolls, presses, touches or types. Transparent.getReloadPosition() returns {top, left} when the current load is such a reload, null otherwise: a script that runs before the bundle can read the same key to avoid painting the top of the page first.

A link marked rel="prefetch" is fetched when the pointer has rested on it for prefetch_delay ms (65), or at once when a finger touches it. Only a plain GET to another page of this origin: not a download link, not one with a target other than _self, not an excepted path, not a nest path, not the current page. Two prefetches at most are on the wire at a time; the answer (2xx only) is kept by address for prefetch_ttl ms (30 s), prefetch_max (15) pages at most.

<a href="/articles/42" rel="prefetch">Next article</a>

The request carries X-Purpose: prefetch and Purpose: prefetch (no X-Requested-With): a server may skip what a real visit does (counting a view, writing a session). A click on the link then swaps the prefetched page in without a request; a click while the prefetch is still on the wire waits for it. A prefetch never reports a failure: the click then makes its own request.

Transparent.prefetch(href) prefetches an address by script (the same rules of count and cache, no check of the link); getPrefetched(href), setPrefetched(href, html), clearPrefetched() and claimPrefetch(href, callback) reach the cache.

When the connection drops or the server cannot be reached (status 0, nothing received), the page stays as it is, at its address, and is announced again as a swap announces one (DOMContentLoaded, transparent:load, load): what a site put on for the wait - a curtain, a progress bar - comes off through the handlers it already has. jQuery's ajaxError tells the failure.

A status does not make an answer unusable. When the answer is a page of the site - the block the navigation swaps (identifier) is in it, in a layout it can swap - it is shown like any other: in place, at its address, with its history entry (which keeps the status), and nothing is said in the console. That is a form the server refuses, sent back with its errors under 422 (what Symfony answers for an invalid form), a creation under 201, and the site's own page for what is missing, forbidden or broken (404, 403, 500, 503).

An answer that is no such page - a proxy's 502, a debug trace, a bare server error - is a failure: Transparent.rescue() says so in the console (Rescue mode.. called; Unexpected XHR response for a 5xx), replaces the document with what came, and Transparent.isRescueMode() is true from then on.

The rescue sets the answer's head and body as the document's (innerHTML) and runs their scripts through evalScript() (each once, as in the swapped block: see The head and the scripts). An answer with nothing in it (no head and no body, or both empty: a 500 that came with an empty page) is not put in place of the document, which would leave a blank page: the block reads <div class='error'></div> and the page reloads rescue_reload (5000 ms) later. A draft of the form that was being sent is kept (see Form memory) and the form is usable again. While in rescue mode, the page left by Back or Forward is not kept.

Transparent.formMemory keeps what the visitor types in a form, in localStorage, so that a reload, a closed tab, a crash or a session that expired under the form loses nothing. It runs as soon as the script is loaded, ready() or not, disable or not.

  • Which forms: a form with a name or an id attribute, without data-no-persist. Same-named forms (one reply form per comment) are told apart by their action.
  • Which fields: named ones, except password, file, buttons, hidden fields whose name contains _token or csrf, and any field with data-no-persist.
  • When saved: 500 ms after a keystroke or a change made by the visitor (trusted events only: a script filling a field is not saved), at each submission, and at beforeunload when the visitor typed something.
  • When given back: at DOMContentLoaded, the page's first and each swap's; each field given back gets data-restored-from-draft. A <textarea data-edjs> (Editor.js) gets the value in data-edjs too.
  • When dropped: once the server answered the submission and the next page is in - the form is on it again (accepted, or sent back with its errors) or the visitor was sent elsewhere: dropped; the next page asks for a password (a sign-in page: the session had died): kept, and given back the next time that form is on screen; no answer at all (network error, rescue): kept. Also after 7 days, and by Transparent.formMemory.clear(form).

The key is "tx-form-memory:" + location.pathname + ":" + (name or id), plus "@" and what the form's action adds to the page's address; the record is {t, d} (time, fields) and s: "lost" for a draft kept from a sign-in bounce. sessionStorage["tx-form-memory:pending"] holds the submission awaiting its verdict in this tab.

Member Effect
enabled true; false stops saving and restoring
ttl ms a draft lives (7 days)
debounce ms after typing before a save (500)
save(form), restore(form), clear(form) one form
saveAll(), restoreAll() every form of the page (restoreAll settles a pending submission first)
markSent(form), settle(), abandon() the submission's verdict (called by the library)
clearExpired() removes the drafts past ttl (run 2 s after load)
<form name="comment" method="post" action="/articles/42/comments">
    <textarea name="comment[body]"></textarea>
    <input type="hidden" name="comment[_token]" value="...">   <!-- never kept -->
    <input name="comment[captcha]" data-no-persist>              <!-- never kept -->
</form>

An img[data-src] without the class loaded gets its src from data-src when it comes into view (an IntersectionObserver; without one, on scroll and resize, 20 ms throttled). The picture gets loading, then loaded or error; an ancestor .lazybox gets the same classes. It runs at DOMContentLoaded (each swap's too) and at each ready(); Transparent.lazyLoad(images) runs it on a list of one's own. lazyload: false switches it off: none of these runs gives a picture its src, and a picture already watched keeps its data-src when it comes into view. Set before DOMContentLoaded (Transparent.configure({lazyload: false}) in a script of the page) it keeps the first page's pictures out too; given to ready() at the window's load, it stops the lazy loading from then on. navigation.scss hides an img[data-src] until loaded and makes a .lazybox.loading blink.

<div class="lazybox"><img data-src="/media/large.jpg" width="1200" height="800" alt=""></div>

A website within the website: a link whose path matches one of nest's patterns opens its page in a panel over the current one - an iframe with the page, its own scripts and styles - while the host page stays live underneath, at its address. Both sides must agree: the site names the paths, and the page answers with an X-Transparent-Nest header (any value) and a status under 400. Without the header, the link is followed the ordinary way.

Transparent.ready({ identifier: '#page', nest: ['/admin', '/admin/*'] });
// the nested application, on each page it allows in the panel
$response->headers->set('X-Transparent-Nest', '1');

The nested page should load the library and call Transparent.ready() itself, with the same nest patterns: its own navigation then runs inside the panel, and knows which links leave it.

A click on a matching same-origin link (no modifier key, no target="_blank") opens the panel as soon as nest is set (by ready() or configure()); the nest's listener runs in the capture phase, before the page's own click handlers. Inside a nested page, a nest link never opens a second level. The panel (#transparent-nest) appears at once, its page arriving after: html.nest-loading and a bar while the page is fetched (a GET with X-Requested-With, 20 s timeout), html.nested and the body's overflow hidden while the panel is open. The page is written into the iframe (srcdoc) with a <base href> of its real address (data-headlock="true"), and the host's title becomes the nested page's. It is shown when the iframe has loaded and its DOM has been quiet 250 ms (3 s after the load at most, 7 s in all), or when the nested page calls parent.Transparent.notifyNestReady().

Pointing at a nest link prefetches it (15 s; separate from the navigation's prefetch). A network failure shows an error in the panel with a retry button; an answer without the header closes the panel and loads the address in the browser.

Opening pushes one history entry at the host's own address: Back closes the panel. Each page visited inside the panel gets an entry of the host's history too (the address bar stays the host's), so Back and Forward walk through them, replayed from the nested page's own cache when it has them. A link inside the panel to a path outside nest leaves it: the panel closes (parked) and the host navigates there, the panel's entry kept - Back brings the panel back as it was. A redirect inside the panel to such a path does the same.

Control Effect
the chrome bar (drag) moves the panel (nest_move); away from the centre the panel is docked: no backdrop, the host page usable again (html.nest-docked)
a drag past a viewport edge docks it against that edge (data-dock-edge left, right, top, bottom), full height or width, the chrome reduced to a grab tab
a click on the grab tab tucks the docked panel away (is-hidden), only the tab showing; again: back
a double click on the chrome bar back to the centred default
the edges and corners (drag) resize (nest_resize), flush to the viewport's edge within nest_snap_threshold
expand (.transparent-nest-share) full page (is-full): the address bar takes the nested page's address
exit full page (.transparent-nest-collapse) the centred panel and the host's address again
dock (.transparent-nest-dock-toggle) docks into nest_dock_target, else floats the panel docked; again: undock
retry (.transparent-nest-retry) after a network error
close (.transparent-nest-close), a click on the backdrop closes
Escape (on the host or inside the panel) asks nest_esc_confirm, then closes
on a screen of 768 px or less the panel is full screen; a drag of the bar down closes it (nest_swipe)

The buttons' labels are English (Expand to full page, Exit full page, Dock, Retry, Close), set by the script, with no option to change them.

Closing fades the panel out in 300 ms. With nest_keepalive, a panel that showed a page is then parked (is-parked, hidden, the iframe alive): opening the page it shows - or the page it was opened with, unless a form was sent from it since - brings it back as it was left, half-filled forms included. Opening another page discards it; one session at most is parked. With nest_remember, the panel's place, size and dock are kept in localStorage at each close and at pagehide, and given back at the next open.

#transparent-nest: is-loading, is-entering, is-error, is-busy (the nested page is navigating: its <html> has loading), is-closing, is-parked, is-full, is-docked, is-container-docked, is-hidden, is-movable, is-dragging, is-host-dragging, is-swiping, is-chrome-peek, is-chrome-inside, data-dock-edge. Its parts: .transparent-nest-panel (role="dialog", aria-modal; is-free once moved or resized), .transparent-nest-chrome, .transparent-nest-busy, .transparent-nest-grab, .transparent-nest-btn, .transparent-nest-handle-n ... -sw, .transparent-nest-body (the iframe), .transparent-nest-spinner, .transparent-nest-error. On <html>: nested, nest-docked, nest-loading. All are styled by src/css/navigation.scss.

CustomEvents on window; detail.href is the page the panel shows.

Event detail
transparent:nest:loading {href, fresh}: a page is being fetched (fresh: the panel was not open)
transparent:nest:fade-in-start, :fade-in-end {href}
transparent:nest:open, :navigate {href}: the page is shown, first or next
transparent:nest:navigated {href, committed}: the nested page moved on (committed: full page, the address bar follows)
transparent:nest:busy, :idle {href}
transparent:nest:error, :retry {href, fresh}, {href}
transparent:nest:move, :resize {href, rect} (the panel's DOMRect)
transparent:nest:dock {href, edge, target} (edge null for a floating dock, target the selector of nest_dock_target)
transparent:nest:restore, :hide, :show {href}, {href, edge}
transparent:nest:share {href}: full page
transparent:nest:esc, :esc-confirmed, :esc-cancelled {href}
transparent:nest:backdrop, :swipe-close {href}
transparent:nest:fade-out-start, :fade-out-end {href}, {href, parked}
transparent:nest:close none
transparent:nest:resume {href}: a parked session came back
transparent:nest:leave {href}: the panel was left for a host page
Member Effect
Transparent.nest.open(href) (Transparent.openNest) opens the page in the panel (navigates the open panel, resumes a parked one)
Transparent.nest.navigate(href) shows another page in the open panel
Transparent.nest.close(goBack) (Transparent.closeNest) closes; false: without stepping back over the panel's history entry
Transparent.nest.isOpen() (Transparent.isNested) true while a panel is open (not closing, not parked)
Transparent.nest.getContainer() #transparent-nest, or null
Transparent.nest.inScope(href) whether the path matches nest
Transparent.nest.leave(href) closes the panel and navigates the host to href (called from inside)
Transparent.nest.prefetch(href) the hover prefetch
Transparent.nest.notifyReady() (Transparent.notifyNestReady) called by the nested page: show it now
Transparent.nest.notifyNavigated(href, title, info) called by the nested page's navigation after each of its swaps
Transparent.nest.owns(event) whether a popstate belongs to the nest
Transparent.nestCanReplay(href), Transparent.nestReplay(href) inside the iframe: whether a page can be given back from its cache, and doing it
<!-- in the nested page: a close button of its own, and "shown" said explicitly -->
<button type="button" onclick="parent.Transparent.closeNest(); return false">Close</button>
<script>addEventListener('load', () => parent.Transparent && parent.Transparent.notifyNestReady());</script>
Attribute or class On Effect
identifier (#page) the block swapped
data-layout the block its layout: new, same, <previous>-to-<next>
data-layout-prev the block (set) the layout it replaced
data-name the block another name than the current page's: the browser loads the page
rel="prefetch" a link prefetched on hover or touch
data-skip-hash a link to an anchor the hash is not written in the address
target="_blank" a link the browser's
class="reload" a form, its submit button the browser submits it
class="skip-validation" a submit button the browser's validation is skipped
was-validated a form (set) the browser refused it
data-no-persist a form, a field not kept by the form memory
data-restored-from-draft a field (set) given back by the form memory
data-edjs a textarea Editor.js's value, given back too
data-headlock a head element kept across swaps ("false": not)
data-track="reload" a head script or link a change of these loads the page in the browser
data-src, loading, loaded, error, .lazybox a picture, its box the lazy loading
reload, disable <html> of an answer the browser reloads
disable <html> when the script loads the navigation is off
#loader (loader) any element its CSS times give the phases' length

Beside ready(options) and configure(options) (both return Transparent):

Method Returns, does
isRescueMode() true after a rescue
rescue(dom) replaces the document with dom's head and body and runs their scripts
getData(uuid), setData(uuid, data) the data of a history entry ({} when none)
getResponse(uuid) [html, positions] of a cached page; getResponseText(uuid), getResponsePosition(uuid) each half
setResponse(uuid, html, positions), setResponseText, setResponsePosition caches a page (an <html> element also goes to the memory cache)
hasResponse(uuid) whether sessionStorage has it
invalidateResponses() drops every cached page
getLiveResponse(uuid), setLiveResponse(uuid, html, positions), clearLiveResponse() the memory cache (a copy is returned)
prefetch(href), getPrefetched(href), setPrefetched(href, html), clearPrefetched(), claimPrefetch(href, callback) the prefetch cache
getLayout(dom), addLayout(), isKnownLayout(dom) the block's data-layout, noting it as seen, whether it was
isPage(dom), isCompatiblePage(dom, method, data) whether dom has the block; whether it can be swapped in
findLink(event) [method, URL, element or data] of an interaction, or null
findNearestForm(element), formInvalidity(form), refuseInvalidForm(form, element) a control's form; ["name: message", ...]; the refusal described above
scrollTo({top, left, duration, ...}, element, callback) the core's scroll, a tick later (see the core)
scrollToHash(hash, options, callback, element) scrolls to hash's element under the scroll padding
getScrollableElement(root), getScrollableElementXY() the scrolling elements (jQuery set, document order), their [top, left]
getReloadPosition() {top, left} of a reload, or null
lazyLoad(images), findImages(), loadImages() the lazy loading; every picture URL of the page; a promise of those that load within 500 ms
activeIn(callback), activeOut(callback), activeTime(element), callback(fn, delay) the phases; {delay, duration} in ms of an element's CSS
onLoad(uuid, dom, callback, scrollTo, keepScroll) the swap itself
replaceCanvases(dom), transferAttributes(dom), adoptNode(node, target), whenScriptsSettled(callback), evalScript(element) its steps
preload({as: [urls]}, callback) adds <link rel="preload" as crossorigin> for each URL, calls back when all have loaded
isHeadlocked(element) whether a head element stays across swaps
isElement(object), findElementFromParents(dom, parents, from) helpers
parseDuration, debounce, userScroll, remToPixel, emToPixel, parseToPixel, getScrollPadding the core's (see the core); percentToPixel(p, element): p times the element's outer width

Properties: Transparent.settings, Transparent.state (the state class names), Transparent.html (<html> as jQuery), Transparent.loader, Transparent.persistentClasses, Transparent.formMemory, Transparent.nest, Transparent.version ("3.0.31", the line of @glitchr/transparentjs it continues), liveDomCacheMax, liveDomCacheTTL.

Request Headers
a navigation X-Requested-With: XMLHttpRequest, the headers option; a POST's fields as multipart/form-data
a prefetch X-Purpose: prefetch, Purpose: prefetch
a nest page X-Requested-With: XMLHttpRequest; the answer must carry X-Transparent-Nest

A server can answer a navigation as it answers a browser: the same HTML page, any status. It can also answer a form's request with JSON (Content-Type: application/json, exactly), for instance {"code": "302", "target": "/thanks"}.

The bar never blinks. A navigation that completes within progress_bar_delay (200ms: a page from the cache, a static host a few dozen ms away) shows no bar at all; one that takes longer shows it - html.progress-bar-shown - for at least progress_bar_min, then finishes it - html.progress-bar-done, the bar filled and fading over progress_bar_finish - and it goes. Up to 1.5 the bar was drawn for as long as html.loading was set, a few frames on a cached page: a flash at the top of the screen at each click. transparent:progress is dispatched at each step (detail.state: shown, done, hidden), for an indicator of a site's own on the same timing (with progress_bar: false).

Its look is CSS, custom properties on :root (or html):

Property Default Meaning
--transparent-bar-height 3px
--transparent-bar-color #2563eb the streak's colour, and the finished bar's
--transparent-bar-background a streak of the colour any background: a gradient
--transparent-bar-size 50% 100% the background's size: how wide the streak is
--transparent-bar-duration 1s one sweep
--transparent-bar-done the colour the finished bar's fill
--transparent-bar-z 100001

A rainbow that runs along the top:

:root {
    --transparent-bar-height: 4px;
    --transparent-bar-background: linear-gradient(90deg, #ff3b30, #ff9500, #ffcc00, #34c759, #5ac8fa, #007aff, #af52de, #ff3b30);
    --transparent-bar-size: 200% 100%;
    --transparent-bar-duration: 1.4s;
    --transparent-bar-done: linear-gradient(90deg, #ff3b30, #ffcc00, #34c759, #007aff, #af52de);
}

src/js/frame.js (global FlowFrame, also Transparent.frame; part of the navigation entry, no jQuery): a frame of the page whose links and forms load their page in the background and swap it into the frame alone - the visitor's page, its address and its history stay as they are.

<div data-flow-frame="tour" data-flow-frame-src="/tour/start">
    <form data-flow-frame-go>
        <button type="button" data-flow-frame-back aria-label="Back">←</button>
        <button type="button" data-flow-frame-forward aria-label="Forward">→</button>
        <input data-flow-frame-address aria-label="Address">
    </form>
    <div data-flow-frame-view>…the first page's block…</div>
</div>
  • A link of the view (same origin, no target, no download, no modifier key, no data-flow-frame-ignore) or a form is fetched; the block of the answer the select option names ([data-flow-frame-page], the whole body without one) is swapped into the view, whose scroll goes back to the top. Transparent, whose listener is the document's, never hears the click.
  • The frame keeps its own history: [data-flow-frame-back] and [data-flow-frame-forward] go through the pages it showed, given back from memory where their scroll was left; [data-flow-frame-address] says the frame's page and, in a [data-flow-frame-go] form, takes another (same origin only); [data-flow-frame-reload] fetches it again.
  • The browser's Back is not the frame's: the window's history is not touched, so Back leaves the page that holds the frame, as it would leave any page - the frame is a part of the page, not a tab of its own.
  • A page shown in a frame runs no script and its head is not read (the host page's styles apply): a frame shows pages written for it, or the site's own.
  • flow:frame:load (it bubbles; {url, title, frame}) after each page - the moment to set up what it holds; flow:frame:error ({url, status}) when no page came. While a page takes longer than delay (200ms) the frame carries .flow-frame-loading, at least min (300ms): the progress bar's timing.
  • Frames are found by themselves ([data-flow-frame], at load and after each navigation); FlowFrame.attach(element, {select, delay, min, headers}) for options, FlowFrame.get(element) for its frame (go(url), back(), forward(), reload()).

Unlike nest (an iframe panel opened over the site), a frame is in the page, inline, and needs nothing of the server: a static site has them too.

Propose a change docs/navigation.md

Sticky and stops

src/js/sticky.js (global Sticky) and src/js/stops.js (global StickyStops). The options of Sticky are listed in the README and in Sticky.settings; this page describes the address following the reading and the scroll stops, then every option, class, attribute, event and method of each (Sticky needs jQuery and the core; the stops need the core only).

The address follows the reading

With replacehash on (the default), the page's address carries the #id of the headline being read: .sticky-headlines[id], any [id] inside a .sticky-headlines, or a .sticky-magnet[id] (.sticky-headlines-skip left out). A headline is being read once it has reached where its own link would land it - the scroller's scroll-padding-top plus the headline's scroll-margin-top - and until it has scrolled out of sight. At the top, with none reached, the address has no hash. The change is a history.replaceState: it adds nothing to the history.

<section id="menus" class="sticky-headlines" style="scroll-margin-top: 4rem">…</section>

The header that hides, the magnets

A .sticky-top element hides as the page scrolls down past it and shows again on the way up (the classes show and skip-transition, an inline top), unless scrollhide is false or the element carries aria-scrollhide="false". Up to 1.0.2 (and since stickyjs' 2022 versions) the library itself wrote that attribute on every scroll, and no header ever hid: a site that had come to count on its header staying sets scrollhide: false in Sticky.ready(), or the attribute in its template.

Sticky.getMagnets(el) gives the .sticky-magnet elements of el, top to bottom, as {element, offsetTop, offsetLeft, visible} (visible: the share of its scroller's box the magnet fills, 0 to 1); .sticky-magnet-first, -prev, -next and -last scroll to them (Sticky.scrollToNextSnap()...).

With scrollsnap (and passive: false), a scroll of the reader's that leaves a magnet takes the page to the next one in its direction. On the way <html> carries .sticky-prevent-scroll (overflow: hidden) and the movement holds the scroll: a wheel on the way neither stops it nor adds to it, so a quick gesture leads one magnet further, not two. The class is taken off debounce after the movement ended, however it ended.

A way the reader may take: scrollsnap_hold (1.1)

scrollsnap_hold (true) is that held way. Set to false, the way to a magnet is the reader's to take: nothing is set on <html>, and a wheel, a finger, a press or a key on the way stops the page where it is and gives the scroll back at once. The snap does not start again against that gesture: it waits until the scroll has been quiet for debounce, then acts as it always does, at the reader's next scroll. The option is the library's, read each time a way begins: Sticky.ready({...}) or Sticky.set("scrollsnap_hold", false).

Sticky.ready({ scrollsnap: true, passive: false, scrollsnap_hold: false });

The arrow keys: scrollsnap_keys (1.1)

scrollsnap_keys (false): the arrows go from magnet to magnet, with the snap's movement (smoothscroll_duration, smoothscroll_easing) and the way scrollsnap_hold says. <kbd>↓</kbd> / <kbd>↑</kbd> where the magnets follow each other down a scroller - the page, most often -, <kbd>→</kbd> / <kbd>←</kbd> where they follow each other across one (a row with overflow-x: auto holding .sticky-magnet children). The scroller is the one the key is pressed in (the focus inside it, or the pointer over it), the page otherwise; a magnet is met under the scroller's scroll-padding, and a scroller's magnets are its own: the page's keys never count a row's.

<section class="sticky-magnet">…</section>
<section class="sticky-magnet">
    <div class="row" tabindex="0" style="display: flex; overflow-x: auto">
        <article class="sticky-magnet">…</article>
        <article class="sticky-magnet">…</article>
    </div>
</section>
<script>Sticky.ready({ scrollsnap: true, scrollsnap_keys: true });</script>

Down goes on to the next magnet, up back to the one being read, then to the one before. Two presses in a row lead two magnets further (the second goes on from where the first was going); a key held down goes one magnet at a time, each way finished first. The key is left alone - to the browser, or to the page - past the last magnet and before the first, along an axis where no magnets follow each other, in a field (input, textarea, select, contenteditable), on a widget with keys of its own (a slider, tabs, a menu, audio, video), in an open <dialog>, with Shift, Alt, Ctrl or Meta held, inside an element that scrolls by itself along that axis, and when a handler of the page took it (preventDefault): the library listens on the window, in the bubble phase. The keys do not need passive: false: they work with or without the snap under the wheel. Sticky.getMagnetPositions(scroller, axis) gives the scroll positions the keys go through ("y" by default).

The buttons (.sticky-magnet-prev, -next...) are as they were. For .sticky-stop sections, the same keys are the stops' keys option, below.

Sticky.scrollTo(dict, callback, el) and Flow.scrollTo(el, dict, callback) call back once, whatever becomes of the movement, with {completed, reason, top, left}: "arrived" (also when there was no way to go), "interrupted" (the reader took over: a wheel, a finger, a press), "replaced" (another scroll of the same scroller began) or, from Sticky.scrollTo only, "refused" (a script had the scroll already). What a caller sets for the time of a movement, it lifts in the callback; what it does on arrival only, it does when completed. {hold: true} in dict makes a movement the reader's gesture does not stop.

The effects run once per frame in which the scroller scrolled, through the core, and once when the page is loaded. Sticky.trigger(el) runs them now, as $(window).trigger('scrolldelta.sticky') did in stickyjs; a page's own scrolldelta handlers (jQuery) still receive the computed event.

Loading Sticky

import '@glitchr/flowjs/sticky';       // Sticky, its stylesheet, the stops and the core
Sticky.ready({ scrollsnap: true, passive: false, scrollsnap_keys: true });

Without a bundler: jQuery, src/js/core.js, then src/js/sticky.js (it throws "load core.js first" or "jQuery is needed" otherwise), and src/css/sticky.scss compiled. The sticky entry also loads stops.js. Loaded, the module:

  • runs its load pass (Sticky.onLoad()) at once when the document is parsed already, else at DOMContentLoaded - which the navigation dispatches again after each swap, so the pass runs on every page;
  • hears the window's scroll through the core, once per frame, and the .sticky-scrollpercent scrollers it finds;
  • at beforeunload (the browser's, and the one the navigation dispatches before a swap) stops hearing the window and forgets its measurements until the next load pass.

Sticky.ready(options) sets the options (as Sticky.configure), marks the module ready and dispatches sticky:ready on window. The load pass marks it ready too: ready() is for setting options, not a condition for the effects to run.

Options (Sticky.settings)

Set with Sticky.ready({...}), Sticky.configure({...}) or Sticky.set(key, value); read with Sticky.get(key) (null for a key that is not set). The effects read them as they run, so a change applies from the next scroll. Durations are read with Flow.parseDuration ("250ms", "2s", a bare number is seconds).

Option Type Default Effect
debounce duration "500ms" how long after a scroll the measurements are forgotten (Sticky.reset), and how long after a magnet's way the snap waits before it may act again
threshold duration "5000ms" .sticky-bottom: how long the reader must push against the bottom of the page before the bar shows; how long against the top before it goes
scrollcatch boolean or z-index false .sticky-top: when the header has scrolled to the top, a clone takes its place and the header turns position: fixed at that z-index (true: its own). aria-scrollcatch on the element says it for that one
scrolllock boolean true during a held way to a magnet, the wheel and the finger move nothing: wheel (mousewheel where there is no wheel event) and touchmove listeners on the window, not passive, prevent their default until the page is given back (Sticky.disableScroll() / Sticky.enableScroll(); Sticky.allowScrolling() is false meanwhile). The keys are not blocked: the page's overflow is hidden meanwhile, and a magnet key has its own rules. Checked in Chromium; written against the DOM spec, not checked in Firefox
scrollhide boolean true .sticky-top hides on the way down and shows on the way up (aria-scrollhide="false" on one keeps it in place); .sticky-bottom is handled only while it is on
scrollhint number 0..1 0.25 .sticky-bottom: the share of threshold after which the bar shows a hint (class hint; a click on it shows the bar); 0: no hint
overscroll object {top: false, bottom: false, left: false, right: false} written by the module (with passive: false, from the wheel at an edge): the page is pushed past that edge; set by hand for a browser that sends no scroll event there
passive boolean true false lets the module listen to the wheel (wheel.sticky, for the overscroll) and turns the magnets' snap on
scrollpercent boolean true the .sticky-scrollpercent-* triggers and their scrollpercent events
scrollsnap boolean true the magnets: the snap (with passive: false), the .sticky-magnet-* buttons, the keys (with scrollsnap_keys)
scrollsnap_proximity number 0..1 0.01 the share of the screen a magnet must fill to be a candidate for the snap
scrollsnap_hold boolean true the way to a magnet holds the scroll (.sticky-prevent-scroll on <html>); false: the reader's gesture stops it. See A way the reader may take
scrollsnap_keys boolean false the arrows go from magnet to magnet. See The arrow keys
swipe boolean true .sticky-swipe elements dispatch sticky.swipe_left and sticky.swipe_right
swipe_timegate duration "1s" the longest a swipe may take: a number of ms, "1s", "250ms" (read with Flow.toMilliseconds, 1.6)
swipe_threshold number (px) 200 the least a finger must travel sideways for a swipe (and at most 100 px up or down)
swipehint boolean true .sticky-swipehint elements get sticky-swipehint-reveal after swipehint_delay, lost at their first scroll; .sticky-swipehint-container gets an <img> of its data-image
swipehint_delay duration "1s" before the hint is revealed
swipehint_debounce duration 0 when not 0, the hint comes back that long after the element's scroll stopped
autoscroll boolean true .sticky-autoscroll blocks scroll by themselves (both axes unless data-autoscroll-x / -y say otherwise)
autoscroll_bouncing boolean true at the end, the block scrolls back, then over again
autoscroll_speed number (px/s) 100 the speed of the autoscroll
autoscroll_delay duration "2s" before the autoscroll starts, and again after the reader left the block
autoscroll_easing string "linear" the jQuery easing of the autoscroll
autoscroll_minwidth, autoscroll_minheight number (px) 200, 400 a block that scrolls less than that along an axis is not autoscrolled along it
autoscroll_startover boolean true after the reader's pointer or finger left the block, the autoscroll starts again (false: it stops for good)
autoscroll_delay_reverse duration "3s" before a block marked to start reversed scrolls to its end first
autoscroll_reverse boolean false the block scrolls from its end towards its start
autoscroll_mouse_action boolean true a wheel, the pointer entering or a finger on the block stops its autoscroll
smoothscroll_duration duration "250ms" the movement to a magnet (the snap, the buttons, the keys)
smoothscroll_speed number (px/s) 0 instead of the duration, when not 0
smoothscroll_easing string "swing" the jQuery easing of that movement
easein number (px) "100px" .sticky-easein: a margin around the screen - the block shows once it is closer than that to entering it; negative, once it is that far inside (at most its own height)
easeout number (px) "50px" .sticky-easeout: the block is hidden once it is farther than that out of the screen; negative, once less than that of it is still inside
easetime duration "250ms" the duration of a fading block's opacity transition
easedelay duration "250ms" its delay
easethrottle duration "0ms" added to the delay of each fading block after the first, in the page's order: a cascade
ready boolean true the effects run; the module sets it false at beforeunload and true again at the load pass
debug boolean or number false prints to the console; 2 prints every scroll
disable boolean false no effect runs; at the load pass .sticky elements get sticky-disabled
replacehash boolean true the address follows the reading (see The address follows the reading)

Two keys are read without being declared: autoscroll_speed_reverse and autoscroll_duration_reverse (the first movement of a block marked to start reversed; unset, it is at once). Give them with Sticky.set().

easein and easeout are read with parseInt: "100px" is 100, "2rem" is 2. Give px.

Classes and attributes (Sticky)

What the page marks, and what the module sets in return.

Mark Effect Set by the module
.sticky-top a header that hides on the way down, shows on the way up show, skip-transition, an inline top; with scrollcatch: caught, aria-scrollcatch-pos, aria-labelledby (the clone's id), and a clone marked aria-scrollcatch-clone
aria-scrollhide="false" (on .sticky-top) this header stays
aria-scrollcatch="<z-index>" (on .sticky-top) scrollcatch for this one
.sticky-bottom a bar that shows when the reader pushes against the bottom of the page show, hint, skip-transition, an inline bottom
.sticky-widget stays in view (position: sticky in the stylesheet), its offset from its margins and the scroll padding inline top, margin-top, margin-bottom; skip-transition
data-sticky-offset-top, data-sticky-offset-bottom (on .sticky-widget) its distance from the top and the bottom, at least the scroll padding ("2rem", "90px")
.sticky-easein, .sticky-easeout a block that fades in as it comes into the screen, out as it leaves show while shown; an inline transition for its opacity
data-sticky-easein, data-sticky-easeout, data-sticky-easeinout its own easein / easeout (px)
data-sticky-easetime, data-sticky-easedelay its own easetime / easedelay
.sticky-headlines, [id] inside one, .sticky-magnet[id] the headlines the address follows highlight on the current one and on the links to it (a[href^="#id"])
.sticky-headlines-skip a headline the address does not follow
.sticky-magnet a place the snap and the keys take the page to magnet on the one gone to, closest on the candidates
.sticky-magnet-first, -prev, -next, -last buttons: a click scrolls to that magnet show on -first unless at the first magnet, on -last unless at the last; hidden (display: none) without magnets
<html> sticky-prevent-scroll during a held way to a magnet
.sticky-scrollpercent and a class sticky-scrollpercent[-from|-every|-once]-<N>[u|d|l|r] a scroller that dispatches scrollpercent at N % of its scroll, heading down (d, the default), up, left or right
.sticky-swipe an element that dispatches sticky.swipe_left / sticky.swipe_right
.sticky-swipehint a scroller that shows a hint until its first scroll sticky-swipehint-reveal
.sticky-swipehint-container + data-image="<url>" holds the hint's picture a <span><img></span> appended
.sticky-autoscroll a block that scrolls by itself jQuery data autoscroll-prevent, autoscroll-reverse
data-autoscroll-x, data-autoscroll-y true or false: the axes (the autoscroll option by default)
data-autoscroll-speed, -easing, -delay, -minwidth, -minheight, -startover, -reverse, -bouncing, -mouse-action the block's own autoscroll_* option
data-autoscroll-delay-reverse, -speed-reverse, -duration-reverse the first movement of a block marked reversed
data-autoscroll-prevent set, the block does not autoscroll
.sticky sticky-disabled with disable
a[href^="#"] a click notes where its anchor lands, so the header shows there

The scroll-percent class, read by its parts: sticky-scrollpercent-every-50d (each time the scroll passes 50 % going down, again after it went back under), -once-80 (the first time the scroll reaches 80 % going down - once for the life of the window, the navigation's swaps included: the module keeps that state for the window), -from-20u (at every frame of a scroll heading up while the scroll is at 80 % or more: u and l count from the end, 100 - N).

<div class="sticky-scrollpercent sticky-scrollpercent-once-90d" style="overflow-y: auto; height: 20rem">…</div>
<script>
    document.querySelector('.sticky-scrollpercent').addEventListener('scrollpercent', function (e) {
        loadMore();   // e.detail: {scroll, trigger, dir, percent}
    });
</script>

A class on <html> makes the page itself the scroller, and its events go to window.

Events (Sticky)

Event Target When detail
sticky:ready window Sticky.ready() none
scrollpercent the scroller (window for the page), not bubbling a scroll-percent class's mark passed {scroll, trigger, dir, percent}: the computed scroll event, 0 from / 1 once / 2 every, up / down / left / right, the percentage reached
sticky.swipe_left, sticky.swipe_right the .sticky-swipe element, not bubbling a swipe on it none
hashchange window the address followed the reading (a plain HashChangeEvent, without oldURL / newURL)
scrolldelta (jQuery) the scroller a page's own $(window).on('scrolldelta', fn): the jQuery special event, with the computed scroll (deltaX, deltaY, scrollX, scrollY, scrollT, screen, first, last, reset)

The module listens to DOMContentLoaded and beforeunload on window, the window's scroll (through the core), keydown on window (the magnets' keys), touchstart and touchend on window (the swipes), the wheel on window (with the option passive: false), clicks on the magnet buttons and on a[href^="#"].

Methods (Sticky)

Method Effect
Sticky.ready(options) sets the options, marks ready, dispatches sticky:ready; returns Sticky
Sticky.configure(options) sets the options given (an undefined value is skipped)
Sticky.get(key), Sticky.set(key, value) read (null when unset), write one option
Sticky.add(key, value), Sticky.remove(key, value) add a value to (remove one from) a list option
Sticky.scrollTo(dict, callback, el = window) Flow.scrollTo of el, unless a script has its scroll already: then the callback gets {completed: false, reason: "refused"} and nothing moves
Sticky.toMagnet(scroller, {top, left}) the way to a magnet, held or not as scrollsnap_hold says, with the smoothscroll_* movement
Sticky.getMagnets(el) the .sticky-magnet elements inside el, top to bottom: {element, offsetTop, offsetLeft, visible}
Sticky.getMagnetPositions(scroller = window, axis = 'y') the scroll positions that meet a scroller's own magnets, in order, each once, clamped to the scroll
Sticky.getSnap(el), getFirstSnap(el), getLastSnap(el), getPreviousSnap(el), getNextSnap(el) the magnet closest to the scroll, the first, the last, the one before, the one after
Sticky.scrollToSnap(el, callback), scrollToFirstSnap, scrollToLastSnap, scrollToPreviousSnap, scrollToNextSnap scroll there with the smoothscroll_* movement (the first / last: the top / bottom when there is no magnet)
Sticky.listen(el = window, full = true) hear a scroller: every effect, or (full: false) the scroll-percent triggers only
Sticky.unlisten(el = window) stop hearing it
Sticky.trigger(target = window) run the effects now, as if the scroller had scrolled
Sticky.compute(event) the computed scroll event (deltas, percentages, edges, timings)
Sticky.reset(el = window) forget the measurements kept on a scroller (jQuery data)
Sticky.onLoad(el = window) the load pass (next tick): listeners, buttons, swipes, hints, autoscrolls, the first run
Sticky.userScroll(el) Flow.userScroll; true for a selector or a jQuery set that matches nothing
Sticky.getScrollPadding(el) Flow.scrollPadding of el's scroller
Sticky.overscrollTop(event, el), overscrollBottom, overscrollLeft, overscrollRight whether a wheel event pushes past that edge
Sticky.onSwipe(el) names the elements the swipes are told to
Sticky.closestTo(x, numbers), closestToZero(numbers), epsilon(a, b) helpers: the index of the closest number, whether two numbers are within 1
Sticky.parseDuration, parseToPixel, remToPixel, emToPixel, debounce the core's (see Flow)
Sticky.percentToPixel(p, el) parseFloat(p) times el's outer width (no division by 100, as the core's)
Sticky.stops the StickyStops object
Sticky.version "1.7.0", the module's own number

Scroll stops

src/js/stops.js - places on the page a scroll settles on, once it has come to rest. Standalone: no jQuery, and import '@glitchr/flowjs/stops' brings in nothing else.

<header class="sticky-stop" data-sticky-band-down="0">…</header>
<div id="site" class="sticky-stop" data-sticky-band-up="90" data-sticky-band-down="280"></div>

A stop rests where the browser would put an anchor: below the scroller's scroll-padding-top (-left for a row) plus the stop's own scroll-margin-top (-left), the two added. A section with scroll-margin-top: 4rem under a header of that height rests under it, as a link to its id does.

Each stop has a band above and below it, in px (rem and em work too). Once a scroll comes to rest - trackpad momentum included:

  • between two stops it glides on to the one it was heading for, unless it rests inside the band of the stop it just left: that was a nudge, and it settles back. So a band is the least a scroll must travel before the next stop takes;
  • past the last stop (and before the first) it scrolls freely, except inside that stop's band: a scroll back towards the stop, or an overshoot past it, returns to it.

A stop taller than the screen is read freely, from its top until its bottom meets the bottom of the screen; its band below counts from there, and coming back up from the next stop settles on its last screen, not its top. Unless it is barely taller than the screen: a stop at most a quarter of a screen taller (shortEnd, 1.5.2) rests at its top only. Its few extra pixels are read freely all the same, but it is otherwise a stop the screen holds: its band below counts from its top (1.5.3), so a scroll heading down that comes to rest past its end within that band glides back to its top, and one further down goes on to the next stop (within the reach); one heading up goes to its top (with up: false, it rests where it is), and no key stops at its end.

A band never reaches past halfway to the neighbouring stop. The glide is the browser's smooth scroll, which any wheel, touch, key or click interrupts, and a jump under prefers-reduced-motion.

Gentler stops, for a page that should be discovered rather than led:

  • reach (px, or "10rem", "20vh"; Infinity by default): the farthest a glide may go. When the stop a rest would glide to is farther than that, the page stays where it is - only a rest near a stop is completed, and what lies between two stops can be looked at. A band wider than the reach is pointless;
  • duration (ms; 0 by default, the browser's smooth scroll): the glide is animated by the library over that time, on an ease-in-out curve (less for a glide under 100px, in proportion, down to 40%). Any wheel, touch, key or click stops it where it is, and it is not taken up again unless the reader scrolls on;
  • end (false by default): the end of the scroll is a stop too - from the last section a glide goes down to the very bottom (a footer), and one back up, however far: reach does not bind it.
  • ends (false by default, with keys; 1.4): an arrow shows a tall stop's two ends - down, from its top (or from inside it) to its end, its last screen, then on to the next stop's top; up, from its end back to its top, and from the top of the stop below to the end of a tall one before its top. Without it an arrow goes from top to top, however tall the stop.
  • screens (false by default, with keys; 1.5): arrows and page keys read a tall stop a whole screen at a time - its top, a screen further, and so on to its end (its last screen), exactly - then go on to the next stop's top; up, the same back to its top, then to the end of the stop above. Down, from less than a quarter of a screen before that end a key goes on to the next stop rather than to the end; up, a step that would rest less than that under the stop's top goes to the top: a key never makes a short step to rest just before a section. A stop barely taller than the screen has its top alone. It takes the place of ends and of the page keys' own step (seven eighths of a screen) where it is set.
  • shortEnd (a share of the screen, 0.25 by default, a line at the least; 1.5.2): a stop at most that much taller than the screen has one rest, its top - for a scroll at rest, for the arrows with ends, for the page keys and for screens (whose "less than a quarter of a screen" it also sets). data-sticky-short-end on a stop gives it a share of its own (0 keeps its end as a rest down to a line taller than the screen).
  • easing (with a duration): the curve of the glide - a cubic Bezier as CSS writes it, 'cubic-bezier(.65, 0, .35, 1)' by default (gathering speed, then slowing into the stop), its four numbers in an array, one of CSS's names (linear, ease, ease-in, ease-out, ease-in-out), or a function of the time gone (0 to 1) giving the way made. Every glide follows it: the one finishing a scroll at rest, and the one a key asks for, down the page or along a row (data-sticky-easing on a row).
  • settle (ms, 160 by default): how long the scroll must have been quiet before anything happens.
  • up and down (true by default): each way on its own. With up: false a scroll heading up rests where the reader left it - neither carried on to the stop above nor settled back to the one below - while a scroll heading down still goes on to its stop: the page leads the way down and lets the reader wander back up. down: false is the other way round. The reader's direction is what counts (a nudge down that settles back up belongs to the downward scroll), and the keys are not concerned - a key is asked for. A stop says it for itself with data-sticky-up and data-sticky-down ("false", or "true" against the option): a reader heading up is not taken to a stop marked data-sticky-up="false", and one marked data-sticky-up="true" - a full sky at the top of the page - keeps its pull upwards where going back up is otherwise free. On a sideways row up is towards its start, and the row takes the same two attributes.
StickyStops.attach(window, { reach: 140, settle: 320, duration: 700, bandUp: 140, bandDown: 140 });

Keys (1.5)

keys: true lets the keyboard go from stop to stop, with the same glide: <kbd>↓</kbd>, <kbd>PageDown</kbd> and <kbd>Space</kbd> to the next stop, <kbd>↑</kbd>, <kbd>PageUp</kbd> and <kbd>Shift</kbd>+<kbd>Space</kbd> to the previous one (a tall one: its last screen), <kbd>Home</kbd> / <kbd>End</kbd> to the first / last stop.

  • An arrow always goes to a stop: down to the next one, up to the top of the one being read and then to the one before - however tall the stop. A tall stop is read with the wheel, the finger or the page keys: a page key moves one screen inside it, never past its end or top, before going on.
  • A key pressed during a key's glide goes on from where that glide was going: presses chain stop after stop. A key held down goes one stop at a time.
  • A neighbour farther than a screen and a half away (past the last stop, before the first, a long way between two) is left to the browser's own scroll - a separator shorter than half a screen is glided over, which then rests where the key put it. reach does not apply to keys. Shift goes with Space only.
  • Left alone: focus in a field, a frame, an open <dialog>, a widget with keys of its own (slider, tabs, menu, listbox, audio, video) or an element that scrolls by itself; Alt, Ctrl or Meta held; Space on a button or a link; and any key a handler of the page already took (event.defaultPrevented) - the library listens on the window in the bubble phase, after the document's handlers, so a music player's Space stays the player's.
StickyStops.attach(window, { reach: 140, settle: 320, duration: 800, easing: 'cubic-bezier(.7, 0, .2, 1)', bandUp: 140, bandDown: 140, keys: true, end: true });

Sideways (1.5)

axis: 'x' on an element scroller meets the stops by their left edge (its scroll-padding-left) and glides its scrollLeft: the same rest, reach, bands and wide-stop reading, along x - a horizontal wheel, trackpad or swipe settles on the stop it was heading for. With keys, <kbd>←</kbd> / <kbd>→</kbd> go from stop to stop while focus is inside the row or the pointer is over it.

A row marked data-sticky-axis="x" with .sticky-stop children is attached by itself on load, keys on (data-sticky-keys="false" to keep them off), with optional data-sticky-duration, -settle, -reach and -tolerance. Its stops are its own: the page's stops never count them. Leave CSS scroll-snap off the row - it pulls to the nearest item meanwhile.

<div class="photos" data-sticky-axis="x" data-sticky-duration="500" style="overflow-x: auto; display: flex">
    <figure class="sticky-stop">…</figure>
    <figure class="sticky-stop">…</figure>
</div>
StickyStops.attach(row, { axis: 'x', keys: true });
StickyStops.scan();  // attach the data-sticky-axis rows swapped in since

With two stops or more the scroller (<html> for the page) carries --sticky-stop (the index of the stop above), --sticky-progress (0 at that stop, 1 at the next) and --sticky-progress-held (the same, held still across the bands), and dispatches sticky:progress with {index, from, to, top, progress, held} in event.detail when they change, and sticky:stop with {stop, top, axis, key} at each glide. Both bubble to the window.

The window attaches itself when the document has stops at DOMContentLoaded or load. Otherwise, and for scrolling elements:

StickyStops.attach(scroller, { settle: 160, tolerance: 3, reach: Infinity, duration: 0, easing: 'cubic-bezier(.65, 0, .35, 1)', bandUp: 0, bandDown: 0, up: true, down: true, progress: true, smooth: true, axis: 'y', keys: false });
StickyStops.get(scroller).state;      // the last progress, or null
StickyStops.get(scroller).refresh();  // after content moved
StickyStops.pause(); StickyStops.resume();  // while a script scrolls the page itself; nests

The window's stops are measured once and kept while the page keeps its shape (its size, its nodes); a resize, refresh(), a rest and a key measure again, and at each frame of a scroll the two stops the scroll is between are checked. stops.measure() always measures; stops.measure(true) may give the kept measure back.

Two stops at the same place (within tolerance) are one stop - the first in the document. A script that makes stops of its own (markers along a track) should clear the ones it finds before making them: a page the navigation gives back from its cache comes as it was left, the script's markers included, and the script runs again on it. transparent:beforeunload is dispatched before a page is kept, for a script that would rather take its nodes away then.

A scroll a script makes through the library (the navigation giving a position back or going to an anchor, Sticky.scrollTo, Flow.scrollTo, Flow.jump) rests where it was put: the stops only complete the reader's own scrolls.

Sticky.stops is the same object when sticky.js is loaded.

Options (StickyStops.defaults)

Given to StickyStops.attach(scroller, {...}); the window's stops, attached by themselves, take StickyStops.defaults - change a default there before the page loads, or call StickyStops.attach(window, {...}), which merges the options given into the attached stops and measures again. A row marked data-sticky-axis takes its options from its attributes (below).

Option Type Default Effect
selector CSS selector ".sticky-stop" the stops, inside the scroller (<html> for the page)
settle number (ms) 160 how long the scroll must have been quiet to be at rest; a glide that fires no scroll event is given up after twice that
tolerance number (px) 3 within this of a stop is at it; two stops this close are one
reach number (px) or string Infinity the farthest a glide may go: px, "10rem", "2em", "20vh" or "20%" (of the screen); a negative number or an unreadable string is no limit. Not for keys, nor for the end stop
duration number (ms) 0 the glide animated by the library over that time (less under 100 px, down to 40 %); 0: the browser's smooth scroll
easing string, array or function "cubic-bezier(.65, 0, .35, 1)" the curve of an animated glide: cubic-bezier(...), its four numbers, linear, ease, ease-in, ease-out, ease-in-out, or a function of the time gone (0..1); anything unreadable is the default
bandUp, bandDown number (px) 0 the band above and below a stop without data-sticky-band-up / -down
up, down boolean true a scroll heading up (left), down (right) is taken to a stop
progress boolean true the custom properties and the sticky:progress events
smooth boolean true glide; false (or prefers-reduced-motion: reduce): jump
axis "y", "x" "y" "x": the stops are met sideways, by their left edge (an element scroller); fixed when the stops are made
keys boolean false the keyboard goes from stop to stop (see Keys)
ends boolean false with keys: an arrow rests at a tall stop's end before going on
screens boolean false with keys: arrows and page keys read a tall stop a screen at a time
shortEnd number (share of the screen) 0.25 a stop at most this much taller than the screen rests at its top only (at least a line, 40 px; a negative or unreadable value is 0.25)
end boolean false the end of the scroll is a stop too

Attributes (stops)

Attribute On Effect
class="sticky-stop" an element in the scroller a stop (the selector); one with display: none is not counted, nor one inside a row marked data-sticky-axis or inside another attached scroller
data-sticky-band-up, data-sticky-band-down a stop its bands, px ("90", "90px", "2rem", "1.5em"); at most halfway to the neighbour
data-sticky-up, data-sticky-down a stop, or a row "false", "0", "off", "no": a reader heading that way is not taken to it; anything else: they are, against the option
data-sticky-short-end a stop its own shortEnd
style="scroll-margin-top: ..." (-left on a row) a stop where it rests, under the scroller's scroll-padding-top (-left), as an anchor
data-sticky-axis="x" (or "y") a scrolling element holding stops attached by itself, keys on
data-sticky-keys="false" such a row keys off
data-sticky-duration, -settle, -tolerance such a row numbers, its own options
data-sticky-reach, data-sticky-easing such a row its own reach and easing

Written by the stops, on the scroller (<html> for the page), with two stops or more and progress on:

Custom property Holds
--sticky-stop the index of the stop above the scroll, from 0
--sticky-progress 0 at that stop, 1 at the next, four decimals
--sticky-progress-held the same, held at 0 across the upper stop's band below it and at 1 across the lower stop's band above it
.hero { opacity: calc(1 - var(--sticky-progress-held, 0)); }

Events (stops)

Dispatched on the scroller (<html> for the page); both bubble to window.

Event When detail
sticky:stop each glide, a key's included, when it starts {scroller, stop, top, axis, key}: the scroller (window for the page), the stop's element (<html> for the end stop), where it goes, "x" or "y", whether a key asked for it
sticky:progress the scroll moved between two stops (at most once a frame), or the stops were measured again {scroller, index, from, to, top, progress, held}: the index of the stop above, the two stops' elements, the scroll position, the two progress numbers
window.addEventListener('sticky:stop', function (event) {
    analytics.track('section', event.detail.stop.id);
});

The stops listen, through the core, to the scroller's scroll, its interruptions (wheel, touch, press) and the window's resize; to focusin (on the document for the page: a scroll right after a focus change is the browser revealing the field, not the reader); to keydown on window, one listener for every scroller, in the bubble phase; and to the page's moments (Flow.onPage): at each, the marked rows are attached, the rows gone from the page detached, and the window's stops attached (or measured again) when the document has any.

Methods (stops)

Member Effect
StickyStops.attach(scroller, options) the stops of a scroller (the window by default; document, <html> and <body> mean it), made if needed; on stops already attached, merges options and measures again. Returns the instance
StickyStops.get(scroller) the instance, or null
StickyStops.pause(), StickyStops.resume() hold every scroller's stops off (no settle, no key), e.g. while a script scrolls; nests
StickyStops.refresh() measure every scroller's stops again
StickyStops.scan(within) attach the [data-sticky-axis] elements holding stops (in the document, or under within), detach the attached elements no longer in the page
StickyStops.defaults the defaults (above)
StickyStops.Stops the class of an instance
stops.refresh() measure again and report the progress: after content moved
stops.measure(kept) the stops as measured: [{element, top, end, rest, bandUp, bandDown, up, down, shortEnd}], sorted; measure(true) may give the kept measure back
stops.progress() where the scroll is between its two nearest stops (the sticky:progress detail), written to the custom properties; null with fewer than two stops
stops.state the last progress, or null
stops.pause(), stops.resume() hold this scroller's stops off; nests
stops.detach() stops listening, cancels a glide, removes the custom properties, forgets the instance
stops.options, stops.scroller, stops.stops its options, its scroller, the stops last measured

License

MIT since 2026-10-10; earlier versions remain published under LGPL-3.0-or-later.

Propose a change docs/sticky.md

Scroll-driven scenes

src/js/scenes.js (global StickyScenes, entry @glitchr/flowjs/scenes, no jQuery) and its stylesheet src/css/scenes.scss. A scene is a section the page scrolls through while its stage stays pinned to the screen: as if the reader were not scrolling, an object turns, opens, comes in from the side - the scroll is the scene's timeline. New in 1.6.

<section class="sticky-scene" data-scene-length="150vh" data-scene-steps="0.3, 0.6">
    <div class="sticky-scene-stage">
        <div class="device">…</div>
    </div>
</section>
.device { transform: rotateY(calc(-35deg + var(--scene-progress) * 45deg)); }
.device .lid { transform: rotateX(calc(-90deg + var(--scene-progress) * 90deg)); }

The section is its stage's height plus its length (data-scene-length: px, vh, svh, rem...): across that length the stage stays (position: sticky, top: var(--scene-top, 0)), and the scene's progress goes from 0, when the stage reaches its place, to 1, when the scene's end comes up.

What a scene says

At each frame its progress changes:

Where What
--scene-progress 0 to 1, on the section
--scene-step the number of steps passed (data-scene-steps: the progress each step after the first begins at)
--scene-step-progress 0 to 1 inside the current step: one reveal after another
data-scene-state before, active, after (reduced, see below)
data-scene-step the step, for a selector
scene:progress an event on the section (it bubbles): {progress, step, stepProgress, state, frame, scene}

An image sequence

A canvas in the stage draws the picture of the progress:

<canvas data-scene-sequence="/img/turn-{i}.webp" data-scene-frames="90" data-scene-pad="3"></canvas>

{i} is the frame's number, 1 to 90, padded to data-scene-pad digits (or data-scene-frames-list='["a.webp", "b.webp"]'). The first picture loads at once, the others when the browser is idle; while the frame asked for has not arrived, the nearest one loaded is drawn. The canvas says the frame drawn in data-scene-frame, the event in detail.frame.

Reduced motion

With prefers-reduced-motion: reduce a scene is its end (data-scene-reduced on the section, state reduced): progress 1, no length, the stage in the flow of the page - nothing moves as the page scrolls. data-scene-reduced="start" shows its start instead; "live" keeps it moving.

The keyboard

While a scene's stage is pinned, the keys go from step to step (the keys option, on by default; data-scene-keys="false" leaves them to the page):

Key Goes to
<kbd>ArrowDown</kbd>, <kbd>PageDown</kbd>, <kbd>Space</kbd> where the next step begins; from the last step, the scene's end; from its end, the section after it
<kbd>ArrowUp</kbd>, <kbd>PageUp</kbd>, <kbd>Shift</kbd>+<kbd>Space</kbd> where the step before begins, down to the scene's start; from there, the section before it (its last screen when it is taller than one)

With steps 0.3, 0.6, from the scene's start: 0.3, 0.6, 1, then the next section - three presses show the three steps. From the top of the section right after a scene, <kbd>ArrowUp</kbd> comes back to the scene's end, and the steps go back from there. A key pressed during the glide goes on from where the glide is going. The scroll is the browser's (window.scrollTo, smooth, instant under reduced motion); each one is told in scene:key (detail {progress, top, scene}; progress 2 for the section after, -1 for the one before).

The keys are left alone in a field (input, textarea, select, contenteditable), on a widget with keys of its own (a slider, tabs, a listbox, audio, video, a sideways scene's track), in an open dialog, with <kbd>Alt</kbd>, <kbd>Ctrl</kbd> or <kbd>Meta</kbd>, for <kbd>Space</kbd> on what it presses (a button, a link), with <kbd>Shift</kbd> on an arrow (a selection), and when a handler of the page took the key first (preventDefault).

Sideways

data-scene-axis="x" (or { axis: 'x' }): the scene is read by a horizontal scroll, and the page's vertical scroll stays free - the scene never holds the page. It has no length and no pinning; its track is a native overflow-x scroller, so everything that scrolls sideways moves it: a trackpad, <kbd>Shift</kbd>+wheel, a finger's swipe, and the keys on the focused track.

<section class="sticky-scene" data-scene-axis="x">
    <div class="sticky-scene-stage">
        <div class="laptop">…</div>
        <div data-scene-track aria-label="The laptop, opened step by step">
            <article data-scene-panel>Closed</article>
            <article data-scene-panel>Opening</article>
            <article data-scene-panel>Open</article>
        </div>
        <span data-scene-hint aria-hidden="true">→</span>
    </div>
</section>
  • The progress is the track's scrollLeft / (scrollWidth - clientWidth), in the same --scene-progress, --scene-step, data-scene-state and scene:progress as a vertical scene.
  • The steps, unless data-scene-steps names them, are where the panels begin ([data-scene-panel], else the track's children): three full-width panels begin at 0, 0.5 and 1 of the track's range, the steps are 0.5, 1, and data-scene-step is 0, 1, 2 on the three panels - the keys rest at each. css/scenes.scss makes each panel a full-width snap point (scroll-snap-type: x mandatory, scroll-snap-stop: always), the scrollbar hidden, overscroll-behavior-x: contain (no browser Back at the left edge on a trackpad).
  • The track is made focusable (tabindex="0", role="region", its aria-label kept, else "Scene"): <kbd>ArrowRight</kbd> and <kbd>ArrowLeft</kbd> go a step at a time, <kbd>Home</kbd> and <kbd>End</kbd> to the first and the last; the vertical keys stay the page's.
  • At the first movement the scene takes data-scene-touched, and keeps it. The hint ([data-scene-hint], an arrow saying "this way") blinks until then and goes; under reduced motion it is shown still.
  • Reduced motion applies as for a vertical scene (reduced: 'end' by default: the scene at its end, the panels still readable sideways); data-scene-reduced="live" keeps it following the track.

With the stops

A scene marked .sticky-stop is a stop taller than the screen: a scroll comes to rest at its start, and across it the scroll is free - a stop never pulls the reader out of a scene half-way. The scene's keys come first: it listens on the document, StickyStops on the window, and a key the scene took is left to it; once the scene sends the reader to the section after it, the stops' keys go on. Put no stop inside a scene.

iOS, momentum

The progress is read in the frame a scroll is heard in (one listener for the page, Flow's), and for 400ms after the last scroll event (StickyScenes.defaults.tail): iOS tells its page's position a frame or more late (see 1.5.4), and a momentum scroll may end without an event.

API

StickyScenes.attach(section, { length: '150vh', steps: [0.3, 0.6], reduced: 'end', keys: true });
StickyScenes.attach(section, { axis: 'x' });   // read sideways: track, panels
StickyScenes.get(section).progress();   // the progress now
StickyScenes.scan();                    // attach the scenes a script added
StickyScenes.refresh();                 // measure again, after content moved

Scenes are found by themselves (.sticky-scene), at load and after each page the navigation swaps in.

Propose a change docs/scenes.md

StickySortable

src/js/sortable.js - drag-and-drop reordering with Pointer Events (not HTML5 drag-and-drop), so a custom placeholder and touch share one code path. The handle needs touch-action: none in CSS. Needs the core (for the scroller it autoscrolls); jQuery only for the $.fn.stickySortable plugin.

<ul id="list">
    <li data-sortable-item><span class="grip">::</span> One</li>
    <li data-sortable-item><span class="grip">::</span> Two</li>
</ul>
new StickySortable(document.getElementById('list'), {
    handle: '.grip',
    onChange: function (order, container) { /* order: the items, once, on drop */ }
});
$('#list').stickySortable({ handle: '.grip' });   // the same, with jQuery

On a handle, <kbd>ArrowUp</kbd> / <kbd>ArrowDown</kbd> move the item up or down and call onChange.

Loading

Entry Loads
import '@glitchr/flowjs/sortable' src/js/core.js and src/js/sortable.js; no stylesheet: the placeholder and the dragged item are the page's to style
import '@glitchr/flowjs' every module, this one included
<script src="src/js/sortable.js"> after src/js/core.js (and jQuery, for the plugin)

The script throws sortable.js: load core.js first without the core: it registers itself on it (Flow.sortable) and takes jQuery from it. The scroller it autoscrolls is its own choice, not the core's (see Autoscroll). It sets window.StickySortable and Flow.sortable (the same constructor), exports it to CommonJS, and defines $.fn.stickySortable when jQuery is there.

Options (StickySortable.defaults)

Option Default Meaning
handle null selector, relative to the item; null drags the whole item
items '[data-sortable-item]' the items
axis 'y'
dragThreshold 4 px of pointer movement before a press is a drag, not a click
autoscroll true scroll the closest ancestor that scrolls down (overflow-y: auto or scroll), the page otherwise, when an item is dragged to its edge
autoscrollEdge 40 px from the edge that triggers it
autoscrollInsetTop null px of the top edge covered by something pinned; null measures it
autoscrollSpeed 12 px per frame at full deflection
draggingClass 'is-dragging'
placeholderClass 'is-sortable-placeholder'
placeholderVerticalClass 'is-placeholder-vertical' marker between two cards of one row of a grid
placeholderHorizontalClass 'is-placeholder-horizontal' marker between rows
onChange null function(order, container), once on drop, if the order changed
mergeable false dropping in the inner zone of another item fires onMerge instead of reordering
mergeInset 28 px inset that counts as the merge zone
mergeTargetClass 'is-merge-target'
isMergeable null function(el): can the dragged item be a merge source
isMergeTarget null function(el): can an item be a target; falls back to isMergeable
onMerge null function(draggedEl, targetEl, container), on drop, instead of onChange

Each option in detail, with its type:

Option Type Effect
handle CSS selector or null a press counts only when it starts inside an element matching it (closest(), so inside the item or above it); the keyboard works only with a handle
items CSS selector the items are the container's direct children matching it; an element deeper in the container is not an item
axis 'y' or anything else 'y': a single column - the dragged item keeps its left edge and the place is found by comparing vertical midpoints, in the DOM's order. Any other value ('both', 'xy', ...): a grid - the item follows the pointer on both axes and the place is beside the nearest item (see Grids)
dragThreshold number (px) the distance from the press, in a straight line, before the drag starts; under it, a release is a click
autoscroll boolean see Autoscroll
autoscrollEdge number (px) the band at the top and at the bottom of the scroller that scrolls it; at least 90 px under a coarse pointer ((pointer: coarse), a finger)
autoscrollInsetTop number (px) or null added to the top band: the part of the scroller's top covered by a sticky or fixed element; null finds it at the first move of each drag
autoscrollSpeed number (px) the scroll at each animation frame while the pointer is in a band (a constant speed: the band's depth does not change it)
draggingClass class name on the dragged item during the drag
placeholderClass class name the placeholder's class (its only one besides the orientation)
placeholderVerticalClass, placeholderHorizontalClass class names one of them on the placeholder after each move, see The placeholder
onChange function(order, container) or null order is the array of the items (elements) in their new order, container the list; called at a drop that changed the dragged item's index, and at each move by the keyboard
mergeable boolean arms the merge zones, see Merging
mergeInset number (px) the margin, inside an item's box, that still reorders; inside it is the merge zone
mergeTargetClass class name on the item that the dragged one would merge into, while armed
isMergeable function(el) or null called with the dragged item at each move: false and the drag only reorders; null: any item may merge
isMergeTarget function(el) or null called with each candidate: false and that item is never a target; null: isMergeable decides, or every item when it is null too
onMerge function(draggedEl, targetEl, container) or null called at a drop on a merge zone, instead of onChange

StickySortable.defaults is the defaults object itself: a change to it applies to every list made afterwards.

A drag, step by step

  1. Press (pointerdown on the container): ignored during a drag, for a mouse button other than the main one, outside a handle (when there is one), and outside a direct item. The press is kept and its moves are watched.
  2. Past dragThreshold: the drag begins. The item gets draggingClass, the pointer is captured on it, a placeholder takes its place in the list, and the item is lifted out of the flow: position: fixed, its width kept, z-index: 1000, pointer-events: none, top and left following the pointer.
  3. Each move (pointermove on the document, the captured pointer only): the item follows the pointer; with mergeable, a merge zone is looked for first; otherwise the placeholder is moved before or after the item the pointer is nearest (at the end of the list past the last one), its orientation is set, and the autoscroll is updated.
  4. Release (pointerup on the document): the item's inline styles are cleared, it takes the placeholder's place, the placeholder is removed, the autoscroll stops; then onMerge (armed on a target) or onChange (the index changed).

A press released before the threshold is a click: nothing moves, a link in the item is followed. The press is forgotten wherever it ends - a pointerup on the list or anywhere else in the document, a pointercancel, or a move with no button held - so a later move of the pointer over the list starts no drag. A drag the browser cancels (pointercancel: the page took the gesture) puts the item back where it was and calls neither onChange nor onMerge.

The placeholder

An element of the dragged item's tag (li for an li), with the class placeholderClass and two custom properties, the dragged item's size: --sortable-item-height and --sortable-item-width (in px). No inline size: the marker's thickness is the stylesheet's, and the custom properties tell it how long to be.

After each move it gets one of the orientation classes:

Where it stands Class
nothing before it placeholderHorizontalClass (it starts a row)
between two items that share a visual row (overlapping by more than half the shorter one's height) placeholderVerticalClass
between two items on different rows placeholderHorizontalClass
last, with room left on the row for the dragged item placeholderVerticalClass
last, without room placeholderHorizontalClass

A plain column never has two neighbours on one row, so it always reads horizontal.

.is-sortable-placeholder { list-style: none; background: currentColor; opacity: .4; }
.is-sortable-placeholder.is-placeholder-horizontal { height: 3px; width: var(--sortable-item-width); }
.is-sortable-placeholder.is-placeholder-vertical { width: 3px; height: var(--sortable-item-height); }
.is-dragging { box-shadow: 0 6px 18px rgb(0 0 0 / .2); cursor: grabbing; }
.grip { touch-action: none; cursor: grab; }

Grids

With an axis other than 'y', the item nearest the pointer is the one whose box is closest to it (the distance to the nearest point of the box, 0 alongside it), so a wide card next to the pointer wins over a small one whose centre is nearer. The side is read from that item's centre: on the axis where the pointer is farther from the centre, before when the pointer is left of it (or above it), after otherwise.

Autoscroll

The scroller is chosen once, when the list is made: the closest ancestor that scrolls down (its content taller than it, overflow-y auto or scroll), else the page (document.scrollingElement). An ancestor that scrolls only sideways (a table in an overflow-x box) is passed over. instance.scroller holds it.

While the pointer is within autoscrollEdge of the scroller's top (plus the inset of a sticky header) or bottom, the scroller scrolls by autoscrollSpeed px a frame; leaving the band or dropping stops it. Only vertical scrolling is done. The inset under a pinned header is measured once per drag: the element at the middle of the scroller's top line, or one of its ancestors, that is position: sticky or fixed.

Merging

With mergeable: true, a move over an item's inner zone (its box minus mergeInset on each side) arms that item as the merge target: it gets mergeTargetClass, and the placeholder stays where it was while armed. Dropping then calls onMerge(draggedEl, targetEl, container) - the dragged item has been put back at the placeholder's place, and the page decides what a merge is (both replaced by a composite, a group...). onChange is not called for that drop.

new StickySortable(grid, {
    axis: 'both',
    handle: '.card-grip',
    mergeable: true,
    isMergeable: (el) => !el.classList.contains('is-composite'),   // a composite does not merge further
    isMergeTarget: (el) => el.dataset.kind !== 'banner',
    onMerge: (dragged, target) => fetch('/cards/merge', { method: 'POST', body: JSON.stringify([dragged.id, target.id]) }),
    onChange: (order) => fetch('/cards/order', { method: 'POST', body: JSON.stringify(order.map((el) => el.id)) }),
});

The keyboard

keydown on the container: with a handle, <kbd>ArrowUp</kbd> or <kbd>ArrowDown</kbd> pressed on it moves its item before the previous item or after the next one, keeps the focus on the handle, and calls onChange with the new order. At the first or last place nothing happens. Without a handle the keys do nothing; give the handle a tabindex="0" (or make it a button) for it to take the focus. The keys move along the DOM's order, also in a grid.

A tree (StickySortable.Tree)

Nested lists - an item holding a list of its own - reordered and re-parented: a book's chapters and their sections, a menu and its entries. New in 1.6.

<ol id="toc">
    <li data-sortable-item data-id="intro">
        <div class="row"><button class="grip" aria-label="Move">⠿</button>
            <button data-sortable-toggle aria-label="Fold">▾</button> Introduction</div>
        <ol>
            <li data-sortable-item data-id="why"><div class="row"><button class="grip">⠿</button> Why</div></li>
        </ol>
    </li>
    <li data-sortable-item data-id="notes" data-sortable-leaf>…</li>
</ol>
const tree = new StickySortable.Tree(document.getElementById('toc'), { handle: '.grip', maxDepth: 2 });
document.getElementById('toc').addEventListener('sortable:change', (e) => save(e.detail.tree));
tree.serialize();   // [{ id: 'intro', children: [{ id: 'why', children: [] }] }, { id: 'notes', children: [] }]

An item's children are its first ol/ul child (lists); the list is made when an item takes its first child and removed when its last one leaves.

The pointer. Dragged by its handle, the item follows the pointer and a marker (li.is-tree-marker, data-depth and --sortable-depth on it) shows where it will land: between two items, at the level the pointer's horizontal place asks for - one indent (24px) to the right is a child of the item above, one to the left the level of its parent. The level is kept within what the neighbours allow: never deeper than one below the item above, never shallower than the item below (that would take the item below away from its parent), never under an item that accepts no child or is folded, never deeper than maxDepth counting the dragged item's own subtree. On a touch screen the drag begins after a long press (longPress, 350ms, is-held meanwhile); a finger that moves before is a scroll. A pointercancel puts nothing anywhere.

The keyboard, on a focused handle:

Key Does
<kbd>ArrowUp</kbd>, <kbd>ArrowDown</kbd> moves the item before the previous sibling, after the next one
<kbd>ArrowRight</kbd> makes it the last child of the sibling above (unfolded if it was folded)
<kbd>ArrowLeft</kbd> takes it out of its parent, just after it

Each move, or a move refused (the first sibling going up, a leaf, maxDepth), is said in an aria-live="polite" region added after the tree (.sortable-live, visually hidden), in the labels option's words; the focus stays on the handle.

Folding. A [data-sortable-toggle] button in an item folds and unfolds its children (is-collapsed on the item, hidden on its list, aria-expanded and aria-controls on the button); it is hidden while the item has no children. tree.collapse(item), tree.expand(item).

Option (StickySortable.Tree.defaults) Default
handle null the handle's selector, inside an item; null drags the whole item
items '[data-sortable-item]' an item
lists 'ol, ul' an item's own list of children
indent 24 px of horizontal movement that is one level
maxDepth Infinity levels below the root: 0 a flat list, 1 children and no grandchildren
accepts null function (item): may it take children; by default, all but [data-sortable-leaf]
dragThreshold 4 px before a press is a drag
longPress 350 ms a finger holds the handle before the item is dragged
draggingClass, markerClass, collapsedClass is-dragging, is-tree-marker, is-collapsed
labels English moved, nested, outdented, refused, with %item%, %index%, %count%, %depth%, %parent%
onChange null function (detail) after each move

After each move, onChange(detail) and a sortable:change event on the root (it bubbles), detail {item, parent, index, depth, tree}: the item, its new parent item (null at the top), its place among its siblings, its level (0 at the top), and tree.serialize() - nested {id, children}, the id being data-id, else id, else the item's text. tree.destroy() lets go of the listeners and the live region; $(ol).stickySortableTree(options) makes one per root.

The marker has no style of its own; a line at its level is enough:

.is-tree-marker { list-style: none; height: 0; position: relative; }
.is-tree-marker::before { content: ""; position: absolute; inset: -1px 0 auto 0; height: 2px; background: currentColor; }
.is-dragging { opacity: .6; }

Instance

Member Holds or does
new StickySortable(container, options) listens to pointerdown and keydown on the container
container the list
options the options in force (defaults, then the options given)
scroller the element autoscrolled
items() the items, in their order: the direct children matching items
dragging the item being dragged, or null
placeholder the placeholder during a drag, or null
mergeTarget the armed merge target, or null

There is no destroy(): the listeners stay as long as the container. $(el).stickySortable(options) makes one per element, keeps it in el._stickySortable, returns the jQuery set, and does nothing for an element that has one already (the new options are ignored).

A flat StickySortable dispatches no event: onChange and onMerge are the way to hear a change (a tree also says sortable:change). It reads no data attribute besides the default items selector ([data-sortable-item]), and sets only the classes and inline styles above.

Propose a change docs/sortable.md

Ajaxer

src/js/ajaxer.js - named AJAX channels over jQuery's $.ajax. Global Ajaxer (version 1.0.56 of @glitchr/ajaxer, whose options and methods are unchanged). Needs jQuery and the core (src/js/core.js, loaded first), which provides the durations ("250ms", "1s") and $.fn.serializeObject.

import '@glitchr/flowjs/ajaxer';   // the module, with its stylesheet

Ajaxer.setUrl('search', '/search');
Ajaxer.setMethod('search', 'GET');
Ajaxer.setTarget('search', '#results');      // gets the ajaxer-* classes and the loader
Ajaxer.setThrottle('search', 400);          // milliseconds, see below

Ajaxer.send('search', { q: 'miso' },
    function (data) { /* success */ },
    function (data) { /* error: an answer that is not an object */ },
    function ()     { /* complete */ });

A channel is a name. Ajaxer.send(name, data, success, error, complete) sends for that channel (Ajaxer.xhr is the same function); the callbacks are called with the target as this. A channel without a target takes the caller for one when there is a caller to mark: Ajaxer.send.call(element, name, ...) marks that element. Called as Ajaxer.send(...) without a target, nothing is marked and no loader is shown; the request goes all the same and the callbacks are called once, this an empty jQuery object.

Loading

Entry Loads
import '@glitchr/flowjs/ajaxer' src/js/core.js, src/js/ajaxer.js and src/css/ajaxer.scss (a bundler with Sass)
import '@glitchr/flowjs' every module, this one included
<script src="src/js/ajaxer.js"> after jQuery and src/js/core.js

The script throws ajaxer.js: load core.js first without the core and ajaxer.js: jQuery is needed without jQuery. It sets window.Ajaxer and Flow.ajaxer (the same object), and exports it to CommonJS.

At the window's load, Ajaxer.ready() runs by itself (no list, no options); at beforeunload, Ajaxer.clear().

Per-channel settings

Each has a setter taking the channel's name; a value set without a name (Ajaxer.set(key, value), Ajaxer.configure({...}), Ajaxer.ready(list, options)) is the default for every channel.

Setter Key Default Meaning
setUrl(name, url) url none where to send; without it nothing is sent and the console says No target URL provided.
setMethod(name, type) method "POST" HTTP method
setOutput(name, dataType) output "json" jQuery's dataType
setTarget(name, el) target the caller (this), when it is an element or a jQuery object; else none the element marked while the call is out
setLoaderContainer(name, el) loader_container the target where the .ajaxer-loader is put
setThrottle(name, d) throttle 1000 a send within this duration of the last is dropped; 0 for none
setDebounce(name, d) debounce 0 wait this long after the last call
setLoaderDebounce(name, d) loader_debounce 1500 the loader appears only after this long
setRetryLimit(name, n) retry_limit 1 tries after a failure (no answer, or an answer of 500 or more); 0 for none
setQueryLimit(name, n) query_limit none requests in flight at most
cancelable(name, bool) cancelable false at the limit, abort the oldest (true) or refuse the new one
setLoaderThrottle(name, d) loader_throttle none once shown, the loader stays at least that long

A setter stores the value under key[name] in Ajaxer.settings (Ajaxer.setUrl('search', '/s') is Ajaxer.set('url[search]', '/s')); send() reads key[name] first, then the key alone - for every key: Ajaxer.set('url', '/api') is the url of every channel that has none of its own. The setters return nothing (undefined): they do not chain. Ajaxer.cancelable(name) without its second argument sets true. Ajaxer.getTarget(name) reads a channel's target back.

Key Read by send() as Type
url url[name], else url; neither (or an empty string): nothing is sent string
method method[name], else method string (GET, POST, ...)
output output[name], else output jQuery dataType: json, html, text, ...
target target[name], else target, else the caller (this of the call, when it is an element or a jQuery object) element, selector or jQuery object
loader_container loader_container[name], else loader_container, else the target element, selector or jQuery object
throttle throttle[name], else throttle duration
debounce debounce[name], else debounce duration
loader_debounce loader_debounce[name], else loader_debounce duration
loader_throttle loader_throttle[name], else loader_throttle duration
retry_limit retry_limit[name], else retry_limit number
query_limit query_limit[name], else query_limit (read with parseInt; not a number: no limit) number
cancelable cancelable[name], else cancelable boolean

A duration (throttle, debounce, loader_debounce) is a number of milliseconds, or a string with its unit, and is worth what it says: 400 and "400" are 400 ms, "250ms" is 250 ms, "1s" is one second, "1s, 250ms" the longest of the two. 0 (or anything unreadable) is off. Ajaxer.parseTime(v) returns the value in milliseconds. (Up to 1.0.0 the value went through the core's Flow.parseDuration, in seconds, and was then taken for milliseconds: "250ms" switched the throttle off and "1s" made it 1 ms. Plain numbers, the only form that worked, mean what they did.)

loader_debounce is how long a call must be out before the loader shows; loader_throttle (a duration as the others; none by default) is how long the loader stays once it is shown, so that an answer arriving right after it appeared does not make it blink. Without it the loader goes the moment the answer is in. (Up to 1.0.2 the value was stored and nothing read it.)

Throttle, debounce, limit

The order a send goes through:

  1. Throttle (leading edge, per channel name): the first send runs and opens a window of throttle; a send within the window is dropped - no request, no callback. A send whose throttle is off (0) runs and closes any window left open, so a call site that asked for no throttle is never swallowed by another one's window on the same channel.
  2. Debounce (trailing edge, per channel name): with a debounce, the send waits that long and a newer send of the channel replaces it; only the last one goes, with its own data and callbacks. Off (0, the default), it goes at once.
  3. Limit: with a query_limit, when that many requests of the channel are in flight, a channel that is not cancelable refuses the newcomer (no request, no callback); a cancelable one aborts the oldest ones until the newcomer fits.

The default throttle is 1000 ms: two sends of one channel within a second go once. A channel that sends on each keystroke sets setThrottle(name, 0) and a debounce.

Retries

retry_limit is the number of tries after a failure: 1, the default, sends a failed request once more, 3 up to three times more, 0 never. A failure is retried when there was no answer at all (the network) or an answer of 500 or more; an answer under 500 (404, 422...) says what is wrong with the request and is not retried, nor is an aborted request or one jQuery could not read (parsererror). A retry is the same request (its data and callbacks), sent at once and put in the channel's list of requests in flight as the first was, so query_limit counts it and abort() stops it. The failed tries call nothing: complete is called once, at the end of the last try, and the loader and the target show the last try's failure. (Up to 1.6.0 a request was retried only when it was not the first of its channel in flight, and the number was the tries in all: a request sent alone was never retried.)

States

On the target: ajaxer-call while the call is out, then ajaxer-success or ajaxer-error. The loader (.ajaxer-loader, with an .ajaxer-status text) is created in the container on the first send, shown after the loader debounce, hidden on success; on failure it shows the status (404 Error: ..., or Unexpected error) and a click or touch on it retries.

In detail:

When The target The loader
a send goes out every class starting with ajaxer- removed, then ajaxer-call created if the container holds no .ajaxer-loader (a div.ajaxer-loader with a span.ajaxer-status, appended to the container)
loader_debounce later, still out ajaxer-call (shown)
an answer that is an object ajaxer-success; success called ajaxer-call removed (at once, or once loader_throttle is up), ajaxer-success, ajaxer-error removed
an answer that is not an object (a string, a number) ajaxer-error; error called the answer written in .ajaxer-status as HTML, ajaxer-call and ajaxer-error
an HTTP failure (status 400 or more) ajaxer-error .ajaxer-status reads <status> Error, followed by : and the answer's response field (or the whole JSON answer) when there is one; ajaxer-call and ajaxer-error
no answer at all (network) ajaxer-error Unexpected error
an aborted request, or an answer jQuery cannot read as output says (parsererror) nothing nothing
in every case, at the end ajaxer-call removed; complete called

A click or a touch on the loader after a failure clears its status, removes ajaxer-error and sends the same request again (its callbacks included); the listener is added at each failure.

The loader is looked for anywhere inside the container (.find('.ajaxer-loader')): two channels whose containers nest share the inner loader. The stylesheet centres a shown loader in its container (position: absolute, top: 50%, left: 50%): the container needs a position (position: relative). A shown loader without ajaxer-error turns a spinner; .ajaxer-status is hidden while empty.

The callbacks

Callback Called Arguments this
success an answer that is an object (JSON object or array) (data, textStatus, jqXHR) the target, as a jQuery object
error an answer that is not an object (data, textStatus, jqXHR) the target, as a jQuery object
complete at the end of every request that went out, failed or not (jqXHR, textStatus) the target, as a jQuery object

An HTTP failure calls complete only: the error callback is for a successful request whose answer is not an object. success, error and complete are called once per element of the target (a selector matching three elements calls them three times), each time with the whole jQuery object as this.

send() returns nothing: the jqXHR of a channel's requests are in its list (see register() below).

Other methods

Ajaxer.ready(list, options) (fires Ajaxer:ready on the window; also on window load), get, set, add, remove, configure, abort(name, n), abortAll(), register(name, xhr), unregister(xhr), clear() (settings back to the defaults, requests aborted; done on beforeunload), debounce(name, fn, timeout), throttle(name, fn, timeout), isJson(str), isDict(v).

Method Returns Effect
ready(list = {}, options = {}) Ajaxer options.debug switches the console messages on or off; configure(options); dispatches Ajaxer:ready on window. list is not read (kept for the calls written ready(list, options); up to 1.6.0 a non-empty array was stored as the list key, which nothing read)
configure(options) Ajaxer copies every own key of options whose value is not undefined into Ajaxer.settings
get(key) the value, or null Ajaxer.settings[key]
set(key, value) Ajaxer Ajaxer.settings[key] = value
add(key, value) Ajaxer adds value to the array under key (created if missing), once
remove(key, value) the array, or null removes value from the array under key
send(name, data = {}, success, error, complete), xhr(...) undefined sends for the channel: see above. data goes to $.ajax as it is (an object, a string; $('form').serializeObject() from the core)
abort(name, n = -1) undefined aborts the oldest n requests of the channel in flight; all of them when n is negative
abortAll() undefined aborts every channel's requests
register(name, xhr) undefined adds a jqXHR to a channel's list of requests in flight (what query_limit and abort() count); send() does it for its own
unregister(xhr) undefined takes a jqXHR out of every list; send() does it at the answer
clear() undefined Ajaxer.settings back to the defaults object (see Notes), and abortAll()
debounce(name, fn, timeout = null) a function fn debounced under the channel's name: timeout, else the channel's debounce, else debounce
throttle(name, fn, timeout = null) a function fn throttled under the channel's name: timeout, else the channel's throttle, else throttle
parseTime(value) milliseconds see the durations above
isJson(str) boolean JSON.parse(str) succeeds
isDict(v) boolean an object, not null, not an array, not a Date
onLoad() undefined ready(): what the window's load calls
getTarget(name) the target, or null

Ajaxer.version is '1.0.56'. Ajaxer.settings holds the defaults and every key set.

Events

Event Target When
Ajaxer:ready window (a plain Event) at each Ajaxer.ready(), so at the window's load too

The module listens to the window's load (to call ready()) and beforeunload (to call clear()), through jQuery.

A search field

<form id="search"><input name="q"></form>
<div id="results" style="position: relative; min-height: 6rem"></div>
Ajaxer.setUrl('search', '/search');
Ajaxer.setMethod('search', 'GET');
Ajaxer.setTarget('search', '#results');
Ajaxer.setThrottle('search', 0);           // every keystroke may go...
Ajaxer.setDebounce('search', '250ms');     // ...but only the last one after a pause
Ajaxer.setQueryLimit('search', 1);
Ajaxer.cancelable('search');               // a newer search aborts the one in flight
Ajaxer.setLoaderDebounce('search', '300ms');
Ajaxer.setLoaderThrottle('search', '400ms');

$('#search input').on('input', function () {
    Ajaxer.send('search', $('#search').serializeObject(), function (data) {
        this.html(data.html);              // this: $('#results')
    });
});

Notes

The module shares with the core the durations and $.fn.serializeObject, which it used to define itself. It listens to no scroll: the infinite-scroll case in its comments is a caller's, which calls Ajaxer.send from a scroll handler (Flow.onScroll is there for it). Ajaxer.settings is the object of defaults itself, not a copy: clear() assigns it again without copying, so settings set before survive it (kept as in 1.0.56).

The defaults object holds method: "POST", output: "json", cancelable: false, retry_limit: 1, throttle: 1000, debounce: 0, loader_debounce: 1500; loader_throttle, url, target, loader_container and query_limit have no default (each can be given one with Ajaxer.set(key, value) or configure()).

Propose a change docs/ajaxer.md

src/js/cookie.js - what a site keeps in the visitor's browser, and the visitor's say over it. The successor of @glitchr/cookie 1.0.53: the Cookie global and its methods keep their names and their meaning (see From @glitchr/cookie). Needs the core; no jQuery.

import '@glitchr/flowjs/cookie';      // the core and the Cookie global, no stylesheet

Three things are declared by the site, never by the library:

  • categories of consent - a key, the site's words for it, whether it is on before any answer, whether it can be refused;
  • what is stored - each cookie, localStorage or sessionStorage entry, by name or pattern, with its duration and its purpose, under a category;
  • features - what to start when a category is granted and to stop when it is withdrawn: a function, or an inert element of the page.

The library keeps the answer, asks again when it has to, shows the banner and the detailed section, and enforces the answer: nothing is read or written through it under a refused category, and what a withdrawn category governed is erased.

No sentence is written in the library. Every word the visitor reads comes from the configuration (texts, the categories' label and description) or from the site's own HTML.

Entry Loads
import '@glitchr/flowjs/cookie' src/js/core.js and src/js/cookie.js; no stylesheet
import '@glitchr/flowjs' every module, this one included (still no cookie stylesheet)
<script src="src/js/cookie.js"> after src/js/core.js; jQuery is not needed
@import '@glitchr/flowjs/src/css/cookie.scss' the optional minimal look, see The stylesheet

The script throws cookie.js: load core.js first without the core. It sets window.Cookie and Flow.cookie (the same object) and exports it to CommonJS. Loaded twice (two bundles of one page), the second copy keeps the first Cookie (Cookie.flow === true): one answer, one banner. Cookie.version is the library's version (Flow.version).

Nothing is shown before the site declares a category: Cookie.ready() without categories, or no call at all, shows no banner and binds no click. The module works at each moment of a page, as the core gives them (Flow.onPage: the document parsed, the window's load, and every transparent:load of the navigation): it reads the page's configuration blocks, then, once Cookie.ready() has been called, wakes or puts to sleep the inert elements, keeps the banner in the page and shows it while an answer is wanted.

Cookie.ready({
    version: '2026-10',                       // of the categories: changed, the visitor is asked again
    position: 'bottom-left',                  // a word for the stylesheet (data-cookie-position)

    categories: {
        necessary: {
            label: 'Le fonctionnement du site',
            description: 'Votre session et votre panier.',
            required: true,                   // cannot be refused: its switch is locked on
            storage: [
                { type: 'cookie', name: 'PHPSESSID', duration: 'session', purpose: 'Votre session.' }
            ]
        },
        preferences: {
            label: 'Vos préférences',
            description: 'Votre langue, votre thème, votre fuseau horaire.',
            default: true,                    // on until refused
            storage: [
                { type: 'cookie', name: 'USER/*', duration: '30d', purpose: 'Langue, thème, fuseau horaire.' },
                { type: 'local', name: 'theme', purpose: 'Le thème choisi.' }
            ]
        },
        analytics: {
            label: 'La mesure d\'audience',
            description: 'Un identifiant anonyme pour compter les visites.',
            storage: [
                { type: 'cookie', name: '_pk_*', duration: '13mo', purpose: 'Matomo.' },
                { type: 'cookie', name: 'VISITOR_ID', duration: '1y', purpose: 'Un identifiant tiré au hasard.' }
            ]
        },
        location: {
            label: 'Votre position',
            description: 'Pour indiquer la boutique la plus proche.',
            storage: [
                { type: 'session', name: 'position', purpose: 'Votre position, le temps de la visite.' }
            ]
        }
    },

    countdown: { duration: '10s', action: 'default' },   // the site's choice; none by default

    texts: {
        label: 'Les cookies',
        text: { html: 'Le site ne garde que ce qui le fait marcher. <a href="/confidentialite">Confidentialité</a>.' },
        countdown: 'Sans réponse de votre part, ce choix est retenu dans {count} s.',
        accept: 'D\'accord',
        refuse: 'Refuser',
        customize: 'Personnaliser',
        detailsTitle: 'Vos choix',
        acceptAll: 'Tout accepter',
        refuseAll: 'Tout refuser',
        save: 'Enregistrer mes choix',
        close: 'Fermer',
        required: 'Toujours actif'
    }
});

// A measure of audience: started on a yes, stopped on a withdrawal.
Cookie.use('analytics', function () { startMatomo(); }, function () { stopMatomo(); });

// The time zone, remembered only under "preferences".
Cookie.use('preferences', function () {
    Cookie.cookies.set('USER/TIMEZONE', Intl.DateTimeFormat().resolvedOptions().timeZone);
});

// The position, read only under "location".
Cookie.use('location', function () {
    navigator.geolocation.getCurrentPosition(function (position) {
        Cookie.session.set('position', { lat: position.coords.latitude, lng: position.coords.longitude });
        showNearestShop(position);
    });
}, function () { hideNearestShop(); });
<!-- An analysis script that waits for "analytics" -->
<script type="text/plain" data-cookie-category="analytics" data-src="https://stats.example.org/matomo.js"></script>

<!-- A map that waits for "location", and what is shown meanwhile -->
<template data-cookie-category="location"><div id="map"></div></template>
<p data-cookie-placeholder="location">La carte s'affiche une fois votre position autorisée.
    <button type="button" data-cookie-open>Cookies</button></p>

<!-- The footer's link: the detailed section, at any time -->
<a href="#" data-cookie-open>Cookies</a>

The same configuration can be handed by the page instead of a script - what a server that translates its texts does:

<script type="application/json" data-cookie-config>{"version": "2026-10", "categories": {...}, "texts": {...}}</script>

It is read when the page is loaded (and after every page the navigation swaps in), as Cookie.ready() would be called with it. The block is looked for under data-cookie-config whatever the site's prefix - a page can announce its prefix there -, under the site's prefix (data-consent-config), and where the config option says (a selector: config: 'script[data-settings]').

categories in the configuration (an object by key, or a list of {key: ...}), or Cookie.category(key, definition) at any time.

Field Default Meaning
key the category's name: in the answer kept, in Cookie.use(), in data-cookie-category. Not case-sensitive
label, description the key, nothing the site's words, shown in the detailed section
default false its state before any answer: true is on until refused, false waits for a yes
required false cannot be refused: always granted, its switch locked on
storage [] what it stores, see below
features [] names (or {name, label}) of what it governs besides storage, for the detailed section; Cookie.use() with a feature adds to it
onDemand false known (its words are ready) but listed only once something uses it - Cookie.use(), an inert element - and never a reason by itself to ask the visitor again
Cookie.category('chat', { label: 'La messagerie', storage: [{ type: 'cookie', name: 'crisp-client*' }] });
Cookie.category('chat');     // {key, label, description, default, required, onDemand, storage, features, state, granted}
Cookie.categories();         // every category listed, in order
Cookie.state('chat');        // true | false | null (no answer)
Cookie.granted('chat');      // what a script should do now: the answer, else the default

Each entry: {type, name, category, duration, durationLabel, purpose, path, domain} - under a category's storage, in the configuration's storage list (with its category), or by Cookie.storage(entry).

Field Meaning
type cookie (default), local (localStorage) or session (sessionStorage)
name the name, or a pattern with * (_pk_*, USER/*); a RegExp in a script
duration how long it is kept: seconds, or "30d", "12h", "13mo", "1y", "session". The lifetime of a cookie written through the library; shown in the detailed section, in the site's words when it has some (below)
durationLabel the site's words for that duration, shown in its place
label the site's words for the name, shown in its place
purpose the site's words for what it is for
path, domain of the cookie, when it is not the site's default (needed to erase a third party's)

The duration is the cookie's; what the visitor reads of it is the site's. An entry's durationLabel first, else the durations option, else the duration as declared (30d):

durations: { '30d': '30 jours', '13mo': '13 mois', session: 'Le temps de la visite' }     // by duration as declared
durations: function (duration, entry, seconds) { return seconds ? Math.round(seconds / 86400) + ' jours' : null; }

A function that returns nothing leaves the duration as declared. The element keeps it in its attribute (data-cookie-entry-duration="30d").

Reading and writing through the library goes by the declaration:

Cookie.cookies.set('VISITOR_ID', id);          // written for a year, if "analytics" is granted
Cookie.cookies.get('VISITOR_ID');              // null while it is not
Cookie.cookies.remove('VISITOR_ID');
Cookie.local.set('theme', 'dark');   Cookie.local.get('theme');   Cookie.local.remove('theme');
Cookie.session.set('position', {lat: 48.58, lng: 7.75});          // an object is kept as JSON
  • Under a category that is not granted, set writes nothing and returns false, get returns null; with debug: true the console says which entry and which category.
  • An entry that was not declared is refused the same way (undeclared: 'deny', the default); undeclared: 'allow' lets it through.
  • When a category is refused or withdrawn, everything it declares is erased: the cookies matching its names (on the declared path and domain, on /, and on the host's parent domains), the localStorage and sessionStorage entries.
Method Returns Effect
Cookie.cookies.get(name) the value (URI-decoded), or null null too when the cookie is absent, undeclared (with undeclared: 'deny') or its category not granted
Cookie.cookies.set(name, value, options) true, or false when refused writes name=value (encodeURIComponent; an object as JSON). options: path, domain, sameSite, secure, duration - each, when not given, from the entry's declaration, else from the cookie option; the lifetime from the duration (max-age and expires), none for session or no duration
Cookie.cookies.remove(name, options) true erases the cookie whatever the answer, on options.path / options.domain, else the declared ones, on /, on the page's path, on the host and its parent domains
Cookie.local.get(name), Cookie.session.get(name) the string kept, or null an object written as JSON comes back as its JSON text: JSON.parse() it
Cookie.local.set(name, value), Cookie.session.set(name, value) true, or false false too when the storage is closed (a private window) or full
Cookie.local.remove(name), Cookie.session.remove(name) true removes the entry whatever the answer

Cookie.storage(entry | list) declares at any time (returns Cookie); an entry is the same as an earlier one when its type, name and category are, and is then completed. A name declared twice under two categories answers to the first declared.

The durations (duration, record.lifetime, countdown) are read by Cookie.seconds(value), in seconds:

Written Seconds
a number as it is (10 is 10 s)
"45min", "45m" minutes
"12h", "30d", "2w" hours, days, weeks
"13mo" months of 30 days
"1y" years of 365 days
"10s", "250ms", "1.5" through the core's Flow.parseDuration: seconds
"session", "", null, false, anything unreadable null: no lifetime (a cookie for the session; no countdown)

A script that writes with document.cookie or localStorage itself is not stopped by the library: have it wait for its category with Cookie.use().

var on = Cookie.use(category, [options], onGrant, onRevoke);

onGrant runs now if the category is granted, and each time it becomes so; onRevoke each time it is withdrawn. Returns whether it is granted now. A category that is not known yet is declared from options (label, description, default, required, storage) as an on-demand one - the shape of omnibase/consent's Consent.use(name, options, onEnable, onDisable), whose implicit (for default) and erase (name prefixes of cookies) are understood too. options.feature (a name, or {name, label}) adds the feature to the category's list, in the detailed section. Cookie.unuse(category, onGrant, onRevoke) takes them back.

The order of the scripts does not matter: a feature registered before its category is declared follows the declaration when it comes - onGrant runs (and cookie:grant is sent) if the category is declared on until refused or required, onRevoke if it was on and is declared off.

Elements of the page wait for a category too, inert until it is granted:

Markup At the grant At the withdrawal
<script type="text/plain" data-cookie-category="k" [data-src] [data-type]> replaced by a script that runs (once) nothing: a script that ran cannot be taken back - stop it in onRevoke, or reload
<template data-cookie-category="k"> its content is put in the page, before it that content is removed
<iframe data-cookie-category="k" data-cookie-src="..."> (any element with a src) src is set src is removed
<... data-cookie-placeholder="k"> hidden shown

They are looked for when the page is loaded, after every page the navigation swaps in, at each answer, by Cookie.scan(root), and continuously with observe: true (a MutationObserver, for pages built by scripts).

An element woken gets data-cookie-active (a woken element is not woken twice). A script is replaced by a new <script> that takes every attribute of the inert one but type, data-src and data-type, its type from data-type (none: a classic script), its src from data-src, and its inline text. A <template> puts a copy of its content just before itself, and takes those nodes away at the withdrawal. Any other element gets src from its data-cookie-src (and loses it at the withdrawal). A placeholder whose category is not declared stays shown. A data-cookie-category naming a category the site has not declared declares it, on demand: listed in the detailed section from then on, never a reason to ask.

<script type="text/plain" data-cookie-category="analytics" data-type="module">
    import { start } from '/js/stats.js'; start();
</script>
<iframe data-cookie-category="videos" data-cookie-src="https://www.youtube-nocookie.com/embed/xyz" title="The video"></iframe>
<p data-cookie-placeholder="videos">The video shows once "videos" is allowed. <button type="button" data-cookie-open>Choose</button></p>

On window:

Event When detail
cookie:consent an answer was given or changed {categories: {key: bool}, granted: [keys], revoked: [keys], source, pending} - source: accept, refuse, save, countdown, dismiss, signal or api
cookie:grant, cookie:revoke one category went on, or off {category}
cookie:open, cookie:close the banner or the detailed section opened, closed {view} - banner or details
cookie:ready Cookie.ready() was called as in @glitchr/cookie
cookie:confirm, cookie:deny, cookie:check Cookie.refresh(), as in @glitchr/cookie

Cookie.onConsent(fn) listens to the first.

Every event is dispatched on window, does not bubble and cannot be cancelled. In detail:

Event Dispatched detail
cookie:consent after every kept answer (a button, Cookie.save(), accept(), refuse(), grant(), revoke(), setConsent() on a category, the countdown, a click outside, a signal) and after Cookie.forget(); after the features and the inert elements were told categories: every declared category by key, true when granted now; granted, revoked: the keys that changed; source; pending: whether an answer is still wanted
cookie:grant, cookie:revoke for each category whose state changed, after its onGrant or onRevoke functions ran; also when a declaration changes the state of a category a feature waits for {category}: its key, as declared
cookie:open the banner or the detailed section shows, or one replaces the other {view}: banner or details
cookie:close it is closed, or the detailed section is left for the question {view}: the view that closed
cookie:ready at each Cookie.ready() none (a plain Event)
cookie:confirm / cookie:deny at each Cookie.ready() and after each answer (Cookie.refresh()): confirm when at least one group (every declared category is one) is not refused - granted, or without an answer -, deny when every one is refused or there is none none
cookie:check only for Cookie.refresh(null) with no consent counted none

Cookie.onConsent(fn) calls fn(detail, event); Cookie.onConfirm(fn), onDeny(fn) and onCheck(fn) add fn as a listener of their event. Each returns Cookie.

window.addEventListener('cookie:revoke', function (event) {
    if (event.detail.category === 'analytics') window._paq = [];
});
Cookie.onConsent(function (detail) {
    navigator.sendBeacon('/consent-log', JSON.stringify({ categories: detail.categories, source: detail.source }));
});

A banner - the question, accept, refuse, and "customize" - and a detailed section - one row per category with its switch, accept all, refuse all, save. The banner shows by itself while an answer is wanted (Cookie.pending()); anything with data-cookie-open opens the detailed section at any time (data-cookie-open="banner" the banner).

The HTML is the site's, or the library's minimal one:

  • an element with data-cookie-banner in the page is used as it is (a <template data-cookie-banner> is cloned into the page);
  • or template: a selector, an element, HTML, or a function returning one;
  • else a bare default is built - a <div> with the parts below whose words texts has (a button without its word is not made), the classes of classes, and no style. Its texts: label (the dialog's name), title, text, countdown (with {count} where the seconds go), accept, refuse, customize, detailsTitle, detailsText, acceptAll, refuseAll (else accept and refuse), save, close, required.

The bare default is for a site that has no banner of its own (bare: 'auto'). Once the site has shown one - found in a page, or named by root or template - a page where it put none gets none: nothing is built there, the question (and its countdown) waits for the next page that has a banner. bare: true builds the default on such a page, bare: false never builds it.

A page may have several places for the banner - a footer, a bar that folds away on a phone, a corner -, each with its data-cookie-banner. One is the live banner; the others stay hidden.

  • By itself, the library takes the first whose parent the visitor can see (not display: none, visibility: hidden, transparent or under hidden), else the first of the page. A banner left where it cannot be seen opens, the next time, in a place that can.
  • root names it: an element, a selector, or a function returning one, asked again at each opening - the live banner then goes where it says.
  • Cookie.mount(target) puts the live banner there, as it is (its view, its switches, its countdown, the focus): in the place of another [data-cookie-banner], which takes the place it leaves and stays hidden; before a <template data-cookie-banner>; or at the end of any other element. Called before there is a banner, with a [data-cookie-banner], it names the one that becomes it. It returns the banner (null when there is none); a place the site chose this way is kept, seen or not.
Cookie.mount('#footer [data-cookie-banner]');     // the panel now sits in the footer
button.addEventListener('click', function () { Cookie.mount(button.parentNode); Cookie.open(); });

The parts are found by attribute, wherever they are in the banner:

Attribute Part
data-cookie-banner the root; gets data-cookie-position, the state classes and the variables
data-cookie-summary the question; hidden while the detailed section is open
data-cookie-text="key" filled with texts[key] when the configuration has it (left as written otherwise)
data-cookie-accept, data-cookie-refuse every category on / off (but the required ones)
data-cookie-customize opens the detailed section
data-cookie-details the detailed section (a modal dialog)
data-cookie-categories where the rows go
<template data-cookie-row> the site's row, cloned per category; else a default row
data-cookie-toggle, data-cookie-label, data-cookie-description, data-cookie-required, data-cookie-entries, data-cookie-features in a row: the checkbox (role="switch"), the words, the mark of a required category (texts.required), the lists of what it stores (an item per entry, data-cookie-entry="cookie" with its -name, -duration - in the site's words, the duration declared in the attribute - and -purpose) and governs
data-cookie-save keeps the switches as set
data-cookie-close closes without answering
data-cookie-countdown, data-cookie-count the countdown's sentence (shown only while it counts) and the seconds left

The prefix is an option (prefix: 'data-consent'), and each name too (hooks: {category: 'data-consent-script'}).

Every attribute the module reads or sets, by its hooks name (the attribute is hooks[name] when given, else prefix + - + the name):

Hook Attribute (default prefix) Read or set
banner data-cookie-banner read: the banner's places; set on the banner the library uses or builds
summary data-cookie-summary read: the question (without one, the banner itself is the question)
text data-cookie-text="key" read: filled with texts[key]
accept, refuse, customize, save, close data-cookie-accept, ... read: the buttons, by a click anywhere inside them
details data-cookie-details read: the detailed section - in the banner, else anywhere in the page
categories data-cookie-categories read: the list the rows go in (without one, no rows are made)
row data-cookie-row read on a <template>: the site's row; set to the category's key on each row
toggle data-cookie-toggle read in a row: its checkbox; set to the category's key
label, description, required, entries, features data-cookie-label, ... read in a row: where the words, the description, the required mark and the two lists go
entry data-cookie-entry="cookie" set on each item of the entries' list: the entry's type (cookie, local, session)
entry-name, entry-duration, entry-purpose data-cookie-entry-name, ... set on the spans of an item; data-cookie-entry-duration holds the duration as declared
countdown, count data-cookie-countdown, data-cookie-count read: the countdown's sentence and where the seconds go (made from texts.countdown when the sentence has no count inside)
open data-cookie-open, data-cookie-open="banner" read: anything that opens the panel (its default action is prevented)
position data-cookie-position set on the banner from the position option
category data-cookie-category="key" read: an inert element (see Features)
placeholder data-cookie-placeholder="key" read: shown while the category is not granted
src data-cookie-src read: the src an element gets at the grant
active data-cookie-active set on an inert element once woken
config data-cookie-config read: a page's configuration block (data-cookie-config is looked for whatever the prefix)

The module also sets, for screen readers: role="dialog", aria-live="polite" and aria-label (texts.label) on the question, unless they are there; on the detailed section role="dialog", aria-modal="true", tabindex="-1" and aria-label (texts.detailsLabel, else texts.detailsTitle, else texts.label); aria-expanded on the customize buttons; on each switch role="switch", aria-describedby (the description, given an id made from the prefix) and disabled for a required category. The banner and its parts are shown and hidden with the hidden attribute.

classes gives the class of each part of the bare banner, and of each state on the banner the library uses, whoever wrote it. Given in part, it is merged with the defaults. A state class set to an empty value is not set.

Key Default On
root cookie-banner the banner (added to the site's banner too)
summary, title, text cookie-summary, cookie-title, cookie-text the bare banner's question, its title and its text (also the detailed section's title and text)
countdown, count cookie-countdown, cookie-count the countdown's sentence; the span of the seconds (made in the site's banner too)
actions, button cookie-actions, cookie-button the bare banner's rows of buttons; every button
accept, refuse, customize, save, close cookie-accept, ... each button of the bare banner, beside button
details, list cookie-details, cookie-categories the bare detailed section; its list
row, label, toggle, required, description, entries, entry, features cookie-category, cookie-label, cookie-toggle, cookie-required, cookie-description, cookie-entries, cookie-entry, cookie-features the parts of the default row (entry on each item of the entries, in any row)
open is-open the banner while it shows (and a detailed section kept outside it while it shows)
pending is-pending the banner while an answer is wanted
customizing is-customizing the banner while the detailed section shows
counting is-counting the banner while the countdown runs
tick is-tick the seconds' span, set again at each new second (for a pulse); never under prefers-reduced-motion
reduced is-reduced the banner, when the visitor asks for less motion (read when the banner is made)

texts holds every word; a value is a string (written as text) or {html: '...'} (written as HTML). A missing or empty text leaves the site's own words where they are; in the bare banner, a part without its text is not made.

Key Where
label the question's aria-label (and the detailed section's, failing the two below)
title, text the bare banner's title and paragraph; any data-cookie-text="title" / "text"
countdown the countdown's sentence, {count} where the seconds go
accept, refuse, customize the question's buttons
detailsTitle, detailsText the detailed section's title and paragraph
detailsLabel the detailed section's aria-label (else detailsTitle, else label)
acceptAll, refuseAll the detailed section's buttons (else accept, refuse)
save, close the detailed section's buttons
required the mark of a required category, in its row

Any other key is written where a data-cookie-text names it. Configured again after the banner was made (another language), texts, classes, hooks, prefix, position or variables make the bare banner again, and refill the site's.

The bare banner, as headless Chrome printed it one second into a 10 s countdown, for two categories (necessary, required, with PHPSESSID; analytics, with _pk_* for 13mo and durations: {'13mo': '13 mois'}) and the texts label, text, countdown, accept, refuse, customize, detailsTitle, save, close, required (no title, detailsText, acceptAll, refuseAll: those parts are not made):

<div data-cookie-banner="" class="cookie-banner is-pending is-open is-counting">
  <div data-cookie-summary="" class="cookie-summary" role="dialog" aria-label="Les cookies" aria-live="polite">
    <p data-cookie-text="text" class="cookie-text">Le site ne garde que ce qui le fait marcher.</p>
    <p data-cookie-countdown="" class="cookie-countdown">Sans réponse, ce choix est retenu dans <span data-cookie-count="" class="cookie-count is-tick">9</span> s.</p>
    <div class="cookie-actions">
      <button data-cookie-accept="" class="cookie-button cookie-accept" data-cookie-text="accept" type="button">D'accord</button>
      <button data-cookie-refuse="" class="cookie-button cookie-refuse" data-cookie-text="refuse" type="button">Refuser</button>
      <button data-cookie-customize="" class="cookie-button cookie-customize" data-cookie-text="customize" type="button" aria-expanded="false">Personnaliser</button>
    </div>
  </div>
  <div data-cookie-details="" class="cookie-details" hidden="" role="dialog" aria-label="Vos choix" aria-modal="true" tabindex="-1">
    <p data-cookie-text="detailsTitle" class="cookie-title">Vos choix</p>
    <ul data-cookie-categories="" class="cookie-categories">
      <li class="cookie-category" data-cookie-row="necessary">
        <label class="cookie-label">
          <input data-cookie-toggle="necessary" class="cookie-toggle" type="checkbox" role="switch" disabled="">
          <span data-cookie-label="">Le fonctionnement du site</span>
        </label>
        <span data-cookie-required="" class="cookie-required">Toujours actif</span>
        <p data-cookie-description="" class="cookie-description" hidden="" id="data-cookie-description-1"></p>
        <ul data-cookie-entries="" class="cookie-entries">
          <li data-cookie-entry="cookie" class="cookie-entry">
            <span data-cookie-entry-name="">PHPSESSID</span>
            <span data-cookie-entry-duration="session">session</span>
            <span data-cookie-entry-purpose="">Votre session.</span>
          </li>
        </ul>
        <ul data-cookie-features="" class="cookie-features" hidden=""></ul>
      </li>
      <li class="cookie-category" data-cookie-row="analytics">
        <label class="cookie-label">
          <input data-cookie-toggle="analytics" class="cookie-toggle" type="checkbox" role="switch" aria-describedby="data-cookie-description-2">
          <span data-cookie-label="">La mesure d'audience</span>
        </label>
        <span data-cookie-required="" class="cookie-required" hidden="">Toujours actif</span>
        <p data-cookie-description="" class="cookie-description" id="data-cookie-description-2">Un identifiant anonyme.</p>
        <ul data-cookie-entries="" class="cookie-entries">
          <li data-cookie-entry="cookie" class="cookie-entry">
            <span data-cookie-entry-name="">_pk_*</span>
            <span data-cookie-entry-duration="13mo">13 mois</span>
            <span data-cookie-entry-purpose="">Matomo.</span>
          </li>
        </ul>
        <ul data-cookie-features="" class="cookie-features" hidden=""></ul>
      </li>
    </ul>
    <div class="cookie-actions">
      <button data-cookie-accept="" class="cookie-button cookie-accept" data-cookie-text="accept" type="button">D'accord</button>
      <button data-cookie-refuse="" class="cookie-button cookie-refuse" data-cookie-text="refuse" type="button">Refuser</button>
      <button data-cookie-save="" class="cookie-button cookie-save" data-cookie-text="save" type="button">Enregistrer</button>
      <button data-cookie-close="" class="cookie-button cookie-close" data-cookie-text="close" type="button">Fermer</button>
    </div>
  </div>
</div>

(Indented here for reading; the module writes no whitespace between the elements.)

A default row: li.cookie-category[data-cookie-row] holding a label (an input[type=checkbox][data-cookie-toggle] and a span[data-cookie-label]), a span[data-cookie-required], a p[data-cookie-description], a ul[data-cookie-entries] and a ul[data-cookie-features]; empty parts are hidden.

src/css/cookie.scss is an optional, minimal stylesheet (where the banner sits for each position, the switches) drawn from --cookie-* properties; it is not imported by any entry.

Its selectors are the default attributes ([data-cookie-banner], ...): a site with another prefix writes its own. The banner is position: fixed, in the bottom right corner by default; data-cookie-position moves it: a word starting with top to the top, ending with left to the left, ending with center (and top, bottom) centred across, center in the middle of the screen. Everything hidden inside it is display: none; nothing moves under prefers-reduced-motion or .is-reduced.

Property Default
--cookie-z-index 1000
--cookie-gap 1rem the distance to the screen's edges
--cookie-width 24rem at most the screen's width less two gaps
--cookie-padding 1rem
--cookie-ink, --cookie-bg inherit, Canvas the text and the ground
--cookie-border, --cookie-radius, --cookie-shadow 1px solid currentColor, 0, none
--cookie-font inherit the font shorthand
--cookie-transition opacity .2s
--cookie-row-gap .75rem between two categories
--cookie-accent auto the switches' accent-color
--cookie-small .85em the lists of entries and features
--cookie-separator " - " between the name, the duration and the purpose of an entry

The variables option sets such properties on the banner itself.

The position, by order of precedence (1.6):

  1. while the button follow names floats (its position is fixed), next to it - see below;
  2. the position option, or Cookie.defaults.position set once for every banner of the site;
  3. the banner's own data-cookie-position, as written in the site's HTML;
  4. none: the stylesheet's corner, bottom right - what every site had before 1.6, so a site that names nothing sees no change.
Cookie.defaults.position = 'top-right';               // the site's default, before Cookie.ready()
Cookie.ready({ position: 'bottom-center', gutter: 24 });   // or for this configuration
<div data-cookie-banner data-cookie-position="bottom-left">…</div>   <!-- or on the element -->

The six corners and edges are bottom-left, bottom-center, bottom-right, top-left, top-center and top-right; center is the middle of the screen. gutter is the room kept from the screen's edges (--cookie-gap).

Following the cookie button. A site whose cookie button floats in a corner (as omnibase/consent's circle does) names it in follow. While it floats (it, or an element around it, is position: fixed), the banner opens in the corner the button is in - left, centre or right by thirds of the width, top or bottom by halves of the height - just past the button and aligned with it: data-cookie-position says the corner, data-cookie-following is set, and --cookie-follow-block (the distance from the top or bottom edge, past the button) and --cookie-follow-inline (from the left or right edge) hold the distances, measured at each opening and when the window is resized; css/cookie.scss uses them. A button in the flow of the page (not fixed) is not followed: the position above applies.

Cookie.ready({ follow: '.cookie-button', position: 'bottom-left' });

A site with another prefix gets the same attributes under it (data-consent-position, data-consent-following): omnibase/consent, which passes its consent.position, is written exactly as before.

None by default: under the GDPR a silence is not a consent. A site that wants one says so:

countdown: { duration: '10s', action: 'default' }     // or countdown: '10s' - the action is then "default"
action When the time is up
default each category takes its default (what omnibase/consent does today: the site's own on, the rest off)
accept every category on
refuse every category off, but the required ones
none nothing: the countdown ends, the banner stays and waits

It runs only while the question can be read: it waits while the page is hidden (another tab), while the banner is not on screen (a parent folded away, display: none, hidden, visibility: hidden, transparent) and on a page that shows no banner. It goes on across the pages the navigation swaps in, and stops for good once the visitor opens the detailed section or answers. countdown: false, 0 or nothing: no countdown. dismiss gives a click outside the banner the same choice of four (none by default).

A click on what is not the page's - a developer's toolbar, an overlay a browser extension puts in the page - is no click outside: ignore names those elements ([data-cookie-ignore] by default; a selector, an element, or a list of them), and a click inside one neither answers by dismiss nor counts as leaving the question. Cookie.ignored(element) says whether an element is one of them, for a site's own script that closes something on a click elsewhere.

ignore: ['[data-cookie-ignore]', '.sf-toolbar', '[id^="sfwdt"]']   // the Symfony debug toolbar too

A category covered by a privacy signal the site honours is never turned on by a countdown or a click outside.

The visitor is asked again when version is not the one they answered, when a category that is not on demand has no answer (a new one), or when the answer is older than record.lifetime. With a new version or an expired answer, the old answers no longer count: the categories are back to their default - with legacy too: the localStorage cookie/<KEY> the library keeps in step are erased with the answer they copied, never read to bring it back. Cookie.forget() erases the answer. A banner the visitor closed without answering (data-cookie-close, Cookie.close()) is not put again at each page the navigation swaps in: it comes back at the next page loaded, or when there is something new to ask.

signals: { gpc: true, dnt: false }      // or a list of categories: {gpc: ['analytics', 'ads']}

Off by default. When the site asks for it and the browser sends the signal (navigator.globalPrivacyControl, navigator.doNotTrack), the categories it covers (true: every one that is not required) are off without asking; if that leaves nothing to ask, the banner does not show and the refusal is kept (source: 'signal'). The visitor can still open the detailed section and decide otherwise: an explicit choice wins.

The banner is a labelled dialog, announced politely when it shows, and does not take the focus. The detailed section is a modal dialog: the focus goes into it, <kbd>Tab</kbd> stays inside, <kbd>Escape</kbd> closes it, and the focus goes back to what opened it. A switch is a checkbox with role="switch", operated with <kbd>Space</kbd>, described by its category's description. With prefers-reduced-motion, the count does not pulse (classes.tick is not set) and the root has classes.reduced.

Option Default Meaning
debug false the console says what is declared, refused, erased
version null of the categories; a change asks again
categories {} see Categories
storage [] entries with their category, see What is stored
undeclared 'deny' an entry read or written through the library without a declaration: deny or allow
record {store: 'cookie', name: 'cookie-consent', lifetime: '180d'} where the answer is kept, and for how long. store: cookie, local, or {read(), write(text), clear()}. A cookie also takes path, domain, sameSite, secure, by default those of cookie
cookie {path: '/', domain: null, sameSite: 'Lax', secure: 'auto'} the attributes of every cookie the library writes; secure: 'auto' is Secure on https
legacy true keep localStorage cookie/<KEY> in step, as @glitchr/cookie kept its consents, and take the answers found there for a visitor the library has no record of yet - a migration, once: localStorage <record.name>.seen says this browser has had a record, and from then on a record that is gone or no longer counts is a question to ask again
countdown false {duration, action}, see The countdown
dismiss 'none' what a click outside the banner answers: none, default, accept, refuse
ignore '[data-cookie-ignore]' the elements whose clicks are not on the page (a developer's toolbar): a selector, an element or a list; never a click outside. false: none
signals {gpc: false, dnt: false} see Privacy signals
autoOpen true show the banner by itself while an answer is wanted
reopen 'details' what data-cookie-open and Cookie.open() show once answered: details or banner
position null where the banner floats, written in data-cookie-position: bottom-left, bottom-center, bottom-right, top-left, top-center, top-right, center (or any word of the site's stylesheet); null keeps the banner's own attribute, else the stylesheet's corner (bottom right) - see Where it floats
gutter null its distance to the screen's edges: px (a number) or a length, written as --cookie-gap on the banner; null leaves the stylesheet's (1rem)
follow null the cookie button the banner opens next to while that button floats (position: fixed, itself or an element around it): an element, a selector, or a function returning one
container null where a banner the library builds is put (a selector or an element); the end of <body>
template null the banner's HTML: a selector, an element, a string of HTML, a function
root null which element of the page is the banner, when it has several places for it: an element, a selector, a function (asked again at each opening). See Several places in a page
bare 'auto' the bare default banner: 'auto' for a site that has no banner of its own, true wherever a page has none, false never
config null a selector of the page's configuration blocks, besides data-cookie-config and the prefix's own
prefix 'data-cookie' the prefix of every attribute above
hooks {} an attribute's full name, by part: {open: 'data-consent-open'}
classes see the code the class of each part of the default banner and of each state: root, summary, text, actions, button, details, list, row, open, pending, customizing, counting, tick, reduced...
variables {} CSS custom properties set on the banner: {'--cookie-accent': '#24447c'}
texts {} every word: a string, or {html: '...'}
durations {} the site's words for the durations declared: {'30d': '30 jours'}, or a function (duration, entry, seconds)
observe false watch the page for inert elements added later
groups [] @glitchr/cookie's list of groups (Cookie.addGroup()); every category's key, upper-cased, is added to it

How the options add up: Cookie.ready(options) and Cookie.configure(options) may be called any number of times. record, cookie, signals, hooks, classes, variables, texts and durations are merged key by key with what was given before (and with the defaults when read); any other option replaces the one before. categories and storage are not kept as options: each call declares them, adding to what is declared. An option set to undefined is ignored. Cookie.options holds what was configured, Cookie.defaults the defaults.

Types: version any value (compared as a string); undeclared 'deny' or 'allow'; legacy, autoOpen, observe, debug booleans; dismiss 'none', 'default', 'accept' or 'refuse'; reopen 'details' or 'banner'; bare 'auto', true or false; container, root, template an element, a selector, (template) a string of HTML starting with <, or a function called with Cookie returning one; config, prefix strings; hooks, classes, variables, texts objects; durations an object or a function.

record:

Key Type Default Effect
store 'cookie', 'local', or {read(), write(text), clear()} 'cookie' where the answer is kept: a cookie, localStorage, or the site's own store (read() returns the text written, or nothing)
name string 'cookie-consent' the cookie's or the localStorage key's name; also the stem of <name>.seen
lifetime duration '180d' how long an answer counts; the record cookie's lifetime too. null: forever (the cookie for the session)
path, domain, sameSite, secure as in cookie those of cookie the record cookie's attributes

The record is JSON: {"v": version, "t": seconds since 1970, "c": {"KEY": 1 or 0}, "s": source}, the keys upper-cased. A record that cannot be read as such is no record.

cookie (every cookie the library writes, the record's and those of Cookie.cookies.set()):

Key Type Default Effect
path string '/'
domain string or null null none: the host only
sameSite 'Lax', 'Strict', 'None' or empty 'Lax' written as SameSite=... when not empty
secure true, false or 'auto' 'auto' Secure always, never, or on https only

countdown: false, 0 or null (none); a duration ('10s', 10) with the action default; or {duration, action}, action one of default (when not given), accept, refuse, none.

signals: {gpc, dnt}, each false (not honoured), true (covers every category that is not required) or a list of category keys.

hooks: {<hook>: '<attribute>'} for any hook of the table in The interface.

Cookie.ready(options)            // configure and start; may be called again, the options add up
Cookie.category(key[, def])      Cookie.categories()      Cookie.storage(entry | list)
Cookie.state(key)                Cookie.granted(key)      Cookie.pending()      Cookie.answered()
Cookie.accept()                  Cookie.refuse()          Cookie.save({key: bool})
Cookie.grant(key | keys)         Cookie.revoke(key | keys)                      Cookie.forget()
Cookie.use(category, [options], onGrant, onRevoke)        Cookie.unuse(category, onGrant)
Cookie.cookies / Cookie.local / Cookie.session            // .get(name) .set(name, value[, options]) .remove(name)
Cookie.open([view])              Cookie.close()           Cookie.banner()       Cookie.scan([root])
Cookie.mount(target)             // the live banner put there: another [data-cookie-banner]'s place, or any element
Cookie.onConsent(fn)             Cookie.signals()         Cookie.reduced()
Cookie.ignored(element)          // in an element the ignore option names: not a click on the page
Cookie.destroy()                 // as loaded: no category, no declaration, no banner (the answer kept stays)
Method Returns Effect
ready(options) Cookie configure(options), makes the banner again when its words or looks changed, dispatches cookie:ready, starts (signals, inert elements, the banner while an answer is wanted), refresh()
configure(options) Cookie the options added up, the categories and the storage declared; nothing is shown
category(key, definition) the category's snapshot, or null (no key) declares it, or adds to it (label, description, default - or omnibase/consent's implicit -, required, onDemand, storage, features, erase), then starts again when ready
category(key) {key, label, description, default, required, onDemand, storage, features, state, granted} or null a snapshot taken now: changing it declares nothing
categories() snapshots the listed categories, in the order declared (an on-demand one only once used)
storage(entry | list) Cookie declares entries
state(key) true, false, null the answer that counts (true for a required category); null with none, or for a category not declared
granted(key) boolean the answer, else the default (off under an honoured signal); false for a category not declared
pending() boolean a category that is not required nor on demand has no answer that counts
answered() boolean a record of this version, younger than record.lifetime
accept(), refuse() Cookie every listed category on, or off but the required ones (source: 'accept', 'refuse')
save(map) Cookie keeps {key: bool} (source: 'api'); without a map, the switches of the detailed section as set (source: 'save')
grant(keys), revoke(keys) Cookie those categories on or off (a key or a list; unknown keys are skipped); without keys, as accept() / refuse() but with source: 'api'
forget() Cookie erases the record (and cookie/<KEY> with legacy); every category is back to its default, and what a category now off declares is erased; tells what changed (cookie:consent with source: 'api') and asks again
use(key, options, onGrant, onRevoke) boolean: granted now see Features; options may be left out
unuse(key, onGrant, onRevoke) Cookie takes those functions back
scan(root) Cookie wakes or puts to sleep the inert elements under root (the document by default)
open(view, opener) Cookie shows banner or details (the reopen option by default; details falls back to banner when the banner has no detailed section); opener gets the focus back at the closing. Nothing without a declared category
close() Cookie hides the banner; while an answer is wanted, it is not shown again by itself until the next page loaded
banner() element or null the live banner
mount(target) element or null see Several places in a page
onConsent(fn) Cookie fn(detail, event) at each cookie:consent
signals() {gpc, dnt} what the browser sends, whatever the site honours
reduced() boolean prefers-reduced-motion: reduce
ignored(element) boolean inside an element the ignore option names
seconds(value) number or null a duration in seconds, see What is stored
destroy() Cookie stops the countdown and the observer, removes a banner the library put in the page (hides the site's), unbinds the clicks and keys, forgets every option, category, entry and feature; the record stays where it is kept

Cookie.cookies, Cookie.local and Cookie.session are described in What is stored.

package.json: "@glitchr/cookie" gives way to "@glitchr/flowjs"; the import '@glitchr/cookie' becomes '@glitchr/flowjs/cookie' (or nothing, with the whole library). jQuery is no longer needed.

Everything 1.0.53 had is there, with the same meaning:

Cookie.ready(options), Cookie.configure(options), Cookie.options, getOption, setOption, addOption, removeOption as before; ready() without categories shows nothing
Cookie.set(group, name, value, expires, reloadIfNotSet, path) writes the cookie GROUP/NAME (SameSite=Strict; Secure) unless the group is refused
Cookie.get(group, name), Cookie.delete(group, path) as before
Cookie.checkConsent(group), Cookie.setConsent(consent, group), Cookie.hasConsents(group), Cookie.getConsents() localStorage cookie/<GROUP>, as before
Cookie.declare(group), Cookie.addGroup(group), Cookie.refresh() as before
Cookie.onConfirm(fn), Cookie.onDeny(fn), Cookie.onCheck(fn) the events are native ones on window (cookie:confirm...); jQuery's $(window).on() hears them as before
Cookie.reset(el), Cookie.onLoad(el) as before, when jQuery is there

A group whose name is a category's key is that category: Cookie.set() writes when the category is granted (a category with default: false waits for a yes, where a bare group was written until refused), Cookie.get() returns null when it is refused, checkConsent() gives its state and setConsent() answers for it. A group that is no category behaves exactly as in 1.0.53.

Propose a change docs/cookie.md

Testing

Running the tests

JQUERY=path/to/jquery.js npm test                      # tests/*.html in a headless Chrome
JQUERY=path/to/jquery.js npm test ajaxer.html          # one page
JQUERY=path/to/jquery.js CPU=4 node tests/run.mjs perf/scroll.html   # the measurements

tests/run.mjs needs Node 22 or later (its own WebSocket) and Chrome (CHROME=... to name another; TIMEOUT in ms per page, 15000 by default). It serves the repository over HTTP on a free port of 127.0.0.1 - tests/site.mjs answers under /site/ as a small site would, for the navigation to browse - drives the browser over the DevTools protocol, in real time, and opens each page, which writes its verdict in #result. Any console error or uncaught exception a page did not say it expects (Harness.expect) fails it. tests/perf/scroll.html measures what a scroll costs (script time, style and box reads per frame, listeners) and is run by name only. See CHANGELOG.md for what each version changed.

Variable Default Effect
JQUERY required the path of a jquery.js (any project's): served to the pages at /tests/jquery.js. Without it the runner stops with its usage line
CHROME /Applications/Google Chrome.app/Contents/MacOS/Google Chrome the browser to drive (started --headless=new, a fresh profile, a 1280 x 900 window)
TIMEOUT 15000 ms a page has to replace "pending" in #result
CPU none a processor that many times slower (4: about a phone's), applied to each page through the DevTools protocol - for the measurements

Arguments name pages (relative to tests/); none runs every tests/*.html. A page passes when every line of its verdict starts with ok; the runner prints PASS <page> or FAIL <page> with the lines, and exits non-zero when one failed.

In a container

Where Chrome is not installed, an image that has Node 22 and a Chrome (the puppeteer image, for instance) runs the suite; Chrome there needs --no-sandbox, which a two-line wrapper gives:

printf '#!/bin/sh\nexec /path/to/chrome --no-sandbox "$@"\n' > chrome.sh && chmod +x chrome.sh
docker run --rm -v "$PWD":/repo:ro -v /path/to/dir-with-chrome.sh-and-jquery:/scratch:ro \
    -e CHROME=/scratch/chrome.sh -e JQUERY=/scratch/jquery.js -w /repo \
    --entrypoint node ghcr.io/puppeteer/puppeteer:24.10.0 tests/run.mjs

The repository can be mounted read-only: the runner writes nothing in it (the profile goes to the system's temporary folder).

The pages

tests/harness.js is loaded first by every page: it collects the verdict lines, turns uncaught errors and unhandled rejections into failures (unless Harness.expect(/text/) says one is expected), counts the native listeners added by target and type (so a page can say how many a module costs), and gives the waits a scroll test needs.

Page What it checks
ajaxer.html a channel sends through $.ajax and calls back with the JSON; the loader and the ajaxer-success / ajaxer-error classes; throttle, debounce, loader timings, query_limit, abort()
ajaxer-channels.html a channel without a url sends nothing; without a target, Ajaxer.send() sends and calls back once, and .call(element) marks that element; retry_limit counts the tries after a failure (500: retried, 404: not), a retry is in the channel's list; a value set without a name is every channel's default (url, target, loader_container, query_limit); ready(list) stores no list
cohabitation.html the navigation and the stops together: a scroll the navigation makes rests where it was put, the reader's own still settles; the listeners after a swap
core-scroll.html Flow.scrollTo at a speed takes its way to go over the speed, up as down; a wheel on an element scroller stops its movement, and the listener goes with it
cookie.html Cookie without its banner: what @glitchr/cookie 1.0.53 did, under the same names; categories, states, defaults, storage
cookie-banner.html the banner: shown on a first visit, labelled, accept and refuse, the detailed section, the switches
cookie-countdown.html the countdown and its actions (default, accept, refuse, none)
cookie-navigation.html the cookie module across the navigation's swaps; a configuration handed over as JSON
cookie-places.html a page with several places for the banner; Cookie.mount()
navigation.html a link swaps the block in place, with its title, address, history entry and events; the classes of <html>, the head, a canvas; anchors
navigation-back.html Back to a long page that is not whole yet when its position is given back
navigation-nest.html the nest: a panel over the page, its own history, closing it
navigation-swap.html a script of the swapped block runs once, as on a page load (its var a global, a module as a module); a link with download is left to the browser; <previous>-to-<next> stays on <html> while the new block comes in
navigation-options.html lazyload: false (a picture coming into view, or brought by a swap, keeps its data-src); skip_transition_for_cache (Back and Forward from the cache without the view transition)
navigation-rescue.html a failure that comes with an empty page: the block says so and the page reloads rescue_reload later (the page is reloaded, then gives its verdict)
navigation-status.html answers under 422, 404, 500...: shown in place, no rescue
no-jquery.html the core, the stops and the sortable on a page without jQuery; the others say so when loaded
replace-canvases.html Transparent.replaceCanvases() keeps the page's canvases across a swap
scroll-callback.html a scroll of the core's always ends and calls back once: arrived, interrupted, replaced, refused
sortable-press.html a press released outside the list, cancelled, or followed by a move with no button is forgotten; a drag the browser cancels puts the item back, no onChange
sortable.html a drag below the others reorders and tells onChange once; the keyboard with a handle; a press that does not move is a click
sticky.html the address follows the headline read (highlight); a block fades in; Sticky.scrollTo is a script's scroll
sticky-scrolllock.html scrolllock: a wheel and a touchmove on a held way to a magnet are prevented, allowScrolling() false; given back, the reader's again, no listener left
sticky-effects.html every class the module reads, initialised with every option on, the page scrolled through without an error
sticky-magnets.html the magnets under a wheel, held
sticky-magnets-interruptible.html scrollsnap_hold: false: the reader's gesture stops the way
sticky-magnet-keys.html scrollsnap_keys: the arrows from magnet to magnet, down the page and along a row
stops.html a rest glides on to the stop it was heading for; a nudge settles back; keys; a tall stop read a screen at a time
stops-ends.html ends (1.4), and shortEnd (1.5.2, 1.5.3)
stops-screens.html screens (1.5)
stops-row-keys.html a row data-sticky-axis="x": the arrows sideways, where they are left to the browser
stops-ways.html up: false, down: false, data-sticky-up / -down
stops-lag.html a scroll position told late, as iOS Safari tells it (1.5.4): one glide, all the way
perf/scroll.html not part of the suite: what a scroll costs on a page of forty sections

Testing a page that uses Flow

  • The modules announce what they do with DOM events (transparent:load, sticky:stop, sticky:progress, the cookie events): a site's test waits for them rather than for a delay.
  • A scroll a script makes is not the reader's: Flow.scripted(window) and Flow.userScroll() say which, and the stops never settle after a script's scroll. A test that means to act as the reader dispatches a wheel (or a key) before scrolling.
  • Transparent's cache and the stops' kept measurements live in the page: open a fresh page per test, as tests/run.mjs does (one tab, one load per page).

Propose a change docs/testing.md