@glitchr/flowjs · read at bc6ed6a
Manual
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: import what a page needs.
| Module | Global | Entry | What it does | Needs |
|---|---|---|---|---|
| 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 |
| 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 (a frame that navigates on its own); nested panels, prefetch, form memory, lazy loading | jQuery (FlowFrame: nothing) |
| 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 |
| stops | StickyStops |
stops |
places on the page that a scroll settles on once it comes to rest, with keys and sideways rows | nothing |
| 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 | nothing |
| sortable | StickySortable |
sortable |
drag-and-drop reordering with pointer events | nothing (jQuery only for the $.fn.stickySortable plugin) |
| ajaxer | Ajaxer |
ajaxer |
named AJAX channels over $.ajax: throttle, debounce, loader, retry, request limit |
jQuery |
| cookie | Cookie |
cookie |
what a site stores in the browser and the visitor's consent to it: categories the site declares, 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 |
Per module: the core, navigation, sticky and stops, scenes, sortable, ajaxer, cookie; and testing. Each page holds the module's complete options table, its classes and data attributes, its events and its methods; docs/index.md is the manual's home.
The website, https://glitchr-studio.github.io/flowjs/, runs every module live beside its code and
holds this manual on one page, the API reference JSDoc reads from src/js, and the changelog
(website/, a Symfony application exported statically at each push to master; not part of the npm
package).
Use
Everything, with the stylesheets (bundler with Sass and jQuery provided, e.g.
webpack's ProvidePlugin):
import '@glitchr/flowjs';One module (its stylesheet when it has one, and the core):
import '@glitchr/flowjs/navigation'; // Transparent, FlowFrame
import '@glitchr/flowjs/sticky'; // Sticky
import '@glitchr/flowjs/stops'; // StickyStops
import '@glitchr/flowjs/scenes'; // StickyScenes
import '@glitchr/flowjs/sortable'; // StickySortable
import '@glitchr/flowjs/ajaxer'; // Ajaxer
import '@glitchr/flowjs/cookie'; // CookieWithout a bundler, the files of src/js/ in a page, core.js first:
<script src="jquery.js"></script>
<script src="src/js/core.js"></script>
<script src="src/js/stops.js"></script>Each module sets its global (window.Transparent, ...) and registers itself on
the core (Flow.navigation, Flow.frames, Flow.sticky, Flow.stops, Flow.scenes,
Flow.sortable, Flow.ajaxer, Flow.cookie). The stylesheets are src/css/navigation.scss,
sticky.scss, ajaxer.scss, and index.scss for all of them; 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.
Options
Each module is configured through its own global; the defaults are in the
source, listed here for the ones that matter. The other options of a module are
commented where its settings object starts (src/js/<module>.js).
Transparent - Transparent.ready({...}), defaults in Transparent.settings:
identifier ("#page", the block swapped), loader ("#loader"), throttle
(1000), prefetch (true; only links with rel="prefetch"),
prefetch_delay (65), prefetch_ttl (30000), prefetch_max (15),
exit_duration (0), progress_bar_delay (200), progress_bar_min (400),
progress_bar_finish (250), exceptions ([], paths left to the browser), nest
([], path patterns opened as a nested panel), headlock ([]),
use_view_transitions (false), progress_bar (true), cache_version
(null), lazyload (true), smoothscroll_duration ("200ms"),
disable (false), debug (false). The nest_* options (move, resize, snap,
dock, swipe, keepalive, remember) are all on by default. Details in
docs/navigation.md.
Sticky - Sticky.ready({...}), defaults in Sticky.settings: easein
("100px"), easeout ("50px"), easetime ("250ms"), easedelay,
replacehash (true), scrollhide (true), scrolllock (true),
scrollsnap (true; scrollsnap_hold true: the way to a magnet holds the
scroll, scrollsnap_keys false: the arrows go from magnet to magnet),
swipe (true), swipehint (true), autoscroll
(true, with autoscroll_speed 100 px/s, autoscroll_delay "2s",
autoscroll_minwidth 200, autoscroll_minheight 400, autoscroll_reverse),
smoothscroll_duration ("250ms"), debug, disable. Classes: .sticky-top,
.sticky-bottom, .sticky-widget, .sticky-easein, .sticky-easeout,
.sticky-headlines, .sticky-magnet, .sticky-autoscroll.
StickyStops - StickyStops.attach(scroller, {...}), defaults in
StickyStops.defaults: selector (".sticky-stop"), settle (160), tolerance
(3), reach (Infinity), duration (0), easing, bandUp/bandDown (0),
up/down (true: a scroll heading that way is taken to a stop), progress (true), smooth (true), axis ("y"), keys (false), ends
(false: with keys, an arrow rests at a tall stop's end before going on), screens
(false: with keys, arrows and page keys read a tall stop a whole screen at a time, top to end, then on), shortEnd (0.25: a stop at most this share of a screen taller than the screen rests at its top only, never at its end), end (false). Full description in docs/sticky.md.
StickyScenes - found by themselves (.sticky-scene), or
StickyScenes.attach(section, {...}); defaults in StickyScenes.defaults: length
("100vh", or data-scene-length), steps ([], or data-scene-steps), reduced ("end":
prefers-reduced-motion shows the end), tail (400ms). The progress in --scene-progress, the
scene:progress event, an image sequence on a canvas. See docs/scenes.md.
FlowFrame - found by themselves ([data-flow-frame]), or FlowFrame.attach(element, {...}); defaults in FlowFrame.defaults: select ("[data-flow-frame-page]"), delay (200),
min (300), headers. Its own history, the window's untouched. See
docs/navigation.md.
StickySortable - new StickySortable(container, {...}) or
$(container).stickySortable({...}); defaults in StickySortable.defaults:
handle, items ("[data-sortable-item]"), axis ("y"), dragThreshold
(4), autoscroll (true), onChange, and the merge options. See
docs/sortable.md.
Ajaxer - per-channel setters (Ajaxer.setUrl(name, url), ...); defaults
method "POST", output "json", throttle 1000, retry_limit 1,
loader_debounce 1500. See docs/ajaxer.md.
Cookie - Cookie.ready({...}), or a <script type="application/json" data-cookie-config> in the page; defaults in Cookie.defaults: version
(null; changed, the visitor is asked again), categories ({}: label,
description, default, required, storage, features, onDemand),
storage ([]), undeclared ("deny"), record ({store: "cookie", name: "cookie-consent", lifetime: "180d"}), cookie ({path: "/", domain: null, sameSite: "Lax", secure: "auto"}), countdown (false; {duration, action}
with action "default", "accept", "refuse" or "none"), dismiss
("none"), ignore ("[data-cookie-ignore]": what is not the page's, a
developer's toolbar - a click there is no click outside), signals ({gpc: false, dnt: false}), autoOpen (true),
reopen ("details"), position, container, template, root (which
element is the banner, of several places), bare ("auto": the default banner
for a site that has none), config (a selector of the page's configuration),
prefix ("data-cookie"), hooks, classes, variables, texts ({}:
every word the visitor reads), durations ({}: the words for "30d"),
observe (false), legacy (true), debug. The
methods of @glitchr/cookie are kept. See docs/cookie.md.
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 measurementstests/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.
License
MIT since 2026-10-10; earlier versions remain published under LGPL-3.0-or-later.
Correct this on GitHub README.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-scrollproperty 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.
hashwith 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(aHashChangeEvent, witholdURLandnewURL) is dispatched onwindow, 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
triggerHashChangeis true:hashchange(aHashChangeEvent) is dispatched onwindow. - Inside a
srcdocframe (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.
Correct this on GitHub docs/core.md
Transparent (navigation)
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.
Starting 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 internalx-ajax-requestsetting totrue; - the first time only: checks
cache_versionand dispatchestransparent:first, and starts checkingcache_versionagain each time the tab becomes visible; - each time: dispatches
transparent:ready, addsreadyto<html>, notes the page's layout, starts the lazy loading again; - while
<html>hasfirst: 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>attransparent 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>getstransparent ready disable, every link and form is the browser's.Transparent.ready({ disable: true })(orconfigure): 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).
Options (Transparent.settings)
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.
Every option, read from the source
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 |
What the navigation takes, and what it leaves to the browser
Links
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, anddisableis off;- nothing called
preventDefault()on the click before (an inlineonclickreturningfalsecounts); - the current page has the
identifierblock; - the path matches none of
exceptionsand does not start with/css,/js,/imagesor/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
downloadattribute (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.
Forms
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 asname: messageare said in the console, the form getswas-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
formactionandformmethodfirst, then the form'sactionandmethod; a form withoutactiongoes to its own page, query included. A submission bysubmitevent 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 itsnamewith no other also takes every control of the page whose name starts withname[. - 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.
Anchors of the page
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.
What a swap does
The request
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.
The answer
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 head and the scripts
-
The attributes of
<html>(all butclass),<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-headlockwith any value but"false"; anything added to the head after the script loaded (a widget's style, an analytics tag); or a match of theheadlockoption.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 = falseto keep their order), so it runs as it would in a page load; a stylesheet<link>added is waited for too. Theloadof the swap waits until they have loaded or failed (10 s at most). Abody > scriptthe current body already has (equal node) does not run again. -
Scripts inside the block: with
global_codeon, each<script>of a JavaScript type (no type, a JavaScript MIME type ormodule; neverapplication/json,ld+jsonor a template) is replaced by a fresh copy, which the browser runs once, as on a page load: avarof its own is a global, a module runs as a module,document.currentScriptis 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 anidthat the new block has too is moved into it, drawn as it was; one without anidis kept where the new page has the same place for it.
The block and <html>
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.
The scroll
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.
The announcement
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.
The classes on <html>
| 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: 120With 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).
Events
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).
Back, Forward, and a page that is not whole yet
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.
The cache
| 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.
A reload keeps its place
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.
Prefetch
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.
A request that gets no answer
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 page under another status than 200
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.
Form memory
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
nameor anidattribute, withoutdata-no-persist. Same-named forms (one reply form per comment) are told apart by theiraction. - Which fields: named ones, except
password,file, buttons, hidden fields whose name contains_tokenorcsrf, and any field withdata-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
beforeunloadwhen the visitor typed something. - When given back: at
DOMContentLoaded, the page's first and each swap's; each field given back getsdata-restored-from-draft. A<textarea data-edjs>(Editor.js) gets the value indata-edjstoo. - 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>Lazy loading
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>The nest
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.
Opening
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.
History and addresses
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.
The panel
| 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, parking, resuming
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.
Classes of the nest
#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.
Events of the nest
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 |
API of the nest
| 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>Attributes and classes, all of them
| 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 |
Methods
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.
What the server sees
| 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 progress bar 1.6
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);
}A website in the website: FlowFrame 1.6
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, nodownload, no modifier key, nodata-flow-frame-ignore) or a form is fetched; the block of the answer theselectoption 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 thandelay(200ms) the frame carries.flow-frame-loading, at leastmin(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.
Correct this on GitHub 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. ↓ / ↑ where the magnets follow
each other down a scroller - the page, most often -, → /
← 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 atDOMContentLoaded- 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-scrollpercentscrollers 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";Infinityby 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;0by 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(falseby 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:reachdoes not bind it.ends(falseby default, withkeys; 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(falseby default, withkeys; 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 ofendsand of the page keys' own step (seven eighths of a screen) where it is set.shortEnd(a share of the screen,0.25by 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 withends, for the page keys and forscreens(whose "less than a quarter of a screen" it also sets).data-sticky-short-endon a stop gives it a share of its own (0keeps its end as a rest down to a line taller than the screen).easing(with aduration): 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-easingon a row).settle(ms,160by default): how long the scroll must have been quiet before anything happens.upanddown(trueby default): each way on its own. Withup: falsea 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: falseis 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 withdata-sticky-upanddata-sticky-down("false", or"true"against the option): a reader heading up is not taken to a stop markeddata-sticky-up="false", and one markeddata-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:
↓, PageDown and Space to the next stop,
↑, PageUp and Shift+Space to the
previous one (a tall one: its last screen), Home / End 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.
reachdoes 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, ← / → 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 sinceWith 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; nestsThe 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.
Correct this on GitHub 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 |
|---|---|
| ArrowDown, PageDown, Space | where the next step begins; from the last step, the scene's end; from its end, the section after it |
| ArrowUp, PageUp, Shift+Space | 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, ArrowUp 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
Alt, Ctrl or Meta, for Space on what it presses (a button, a
link), with Shift 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,
Shift+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-stateandscene:progressas a vertical scene. - The steps, unless
data-scene-stepsnames them, are where the panels begin ([data-scene-panel], else the track's children): three full-width panels begin at0,0.5and1of the track's range, the steps are0.5, 1, anddata-scene-stepis0,1,2on 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", itsaria-labelkept, else "Scene"): ArrowRight and ArrowLeft go a step at a time, Home and End 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 movedScenes are found by themselves (.sticky-scene), at load and after each page the navigation swaps in.
Correct this on GitHub 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 jQueryOn a handle, ArrowUp / ArrowDown 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
- Press (
pointerdownon the container): ignored during a drag, for a mouse button other than the main one, outside ahandle(when there is one), and outside a direct item. The press is kept and its moves are watched. - Past
dragThreshold: the drag begins. The item getsdraggingClass, 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,topandleftfollowing the pointer. - Each move (
pointermoveon the document, the captured pointer only): the item follows the pointer; withmergeable, 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. - Release (
pointerupon the document): the item's inline styles are cleared, it takes the placeholder's place, the placeholder is removed, the autoscroll stops; thenonMerge(armed on a target) oronChange(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, ArrowUp or ArrowDown 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 |
|---|---|
| ArrowUp, ArrowDown | moves the item before the previous sibling, after the next one |
| ArrowRight | makes it the last child of the sibling above (unfolded if it was folded) |
| ArrowLeft | 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.
Correct this on GitHub 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:
- 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. - 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. - Limit: with a
query_limit, when that many requests of the channel are in flight, a channel that is notcancelablerefuses the newcomer (no request, no callback); acancelableone 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()).
Correct this on GitHub docs/ajaxer.md
Cookie
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 stylesheetThree 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,
localStorageorsessionStorageentry, 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.
Loading
| 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.
A site's configuration
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
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 defaultWhat is stored
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,
setwrites nothing and returnsfalse,getreturnsnull; withdebug: truethe 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), thelocalStorageandsessionStorageentries.
| 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().
Features
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>Events
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 }));
});The interface
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-bannerin 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 wordstextshas (a button without its word is not made), the classes ofclasses, 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(elseacceptandrefuse),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.
Several places in a page
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 underhidden), else the first of the page. A banner left where it cannot be seen opens, the next time, in a place that can. rootnames 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 (nullwhen 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
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
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.
The stylesheet
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.
Where it floats
The position, by order of precedence (1.6):
- while the button
follownames floats (itspositionisfixed), next to it - see below; - the
positionoption, orCookie.defaults.positionset once for every banner of the site; - the banner's own
data-cookie-position, as written in the site's HTML; - 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.
The countdown
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 tooA category covered by a privacy signal the site honours is never turned on by a countdown or a click outside.
Asking again
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.
Privacy signals
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.
Accessibility
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, Tab stays inside, Escape closes it, and the
focus goes back to what opened it. A switch is a checkbox with
role="switch", operated with Space, 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.
Options (Cookie.defaults)
| 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.
Nested options
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.
Methods
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.
From @glitchr/cookie
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.
Correct this on GitHub 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 measurementstests/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.mjsThe 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)andFlow.userScroll()say which, and the stops never settle after a script's scroll. A test that means to act as the reader dispatches awheel(or a key) before scrolling. Transparent's cache and the stops' kept measurements live in the page: open a fresh page per test, astests/run.mjsdoes (one tab, one load per page).