The component library shows what Facet looks like; this page is
its twin for behaviour. facet.js wires itself to data attributes
on load — pages opt in by writing HTML, never JS — and exposes a
small window.facet API for the moments an app really does need a
call. Every entry here names one behaviour: what it does, the
exact attribute or call, and the same AI instructions an agent
reads in llms.txt.
One call applies configuration live, with no reload: the theme,
light or dark mode, language, page transitions, or any design
token as a direct override. It writes the same html attributes a
page would carry statically, so the markup stays the source of
truth and anything else reading those attributes keeps working.
The skin keys go further and persist for the session: accent1
and accent3 re-ink an accent rank and derive its hover,
pressed and on- companions; accent2 re-tints the pale
secondary rank by mixing the color into the page background
(pale in light mode, deep in dark), because every theme ships
accent-2 as a pale fill; ink recolors the text ramp;
fontHeading and fontBody swap the faces. Null clears a key.
A second argument, { persist: false }, restyles the page now
but writes nothing to storage — for per-load color cycles and
session-only previews.
The facet.js script tag can carry the whole configuration, so a
page boots into its look before first paint — no flash, no
setup script. Explicit attributes on the html tag win over the
script tag, and the URL (?theme=, ?mode=) wins over both, so a
shared link opens exactly as it was sent. The same tag also
carries the opt-ins: data-service-worker, data-location,
data-transition, data-maps-key.
The three-way appearance control: light, dark, or auto — auto
follows the operating system live. The choice is remembered in
the URL and sessionStorage, so it survives reloads and travels in
shared links. Buttons need no JS: data-mode-toggle flips light
and dark, data-theme-switch="velvet" switches the theme, and
both write their choice back to the URL.
<button class="btn" data-mode-toggle>Light / dark</button>
<script>
facet.scheme.set("auto"); // "auto" | "light" | "dark"
facet.scheme.cycle(); // step to the next one
facet.scheme.get(); // the current setting
</script>
facet.translate
Content translation without a framework: a data-i18n group holds
one variant per language and facet.js shows the active one — the
default variant is plain markup, so one language renders with JS
off and crawlers read it. Buttons with data-language-switch="hi"
or one data-language-cycle button flip the page, and the choice
rides the URL as ?language=. Call facet.translate() after
inserting new markup so late content speaks the active language
too; extend facet.strings with your own languages in place.
<p data-i18n>
<span>Save your work</span>
<span data-language="hi" hidden>अपना काम सहेजें</span>
</p>
<button class="btn" data-language-cycle>Language</button>
<script>
facet.translate(); // re-apply after inserting markup
</script>
facet.locate
A coarse sense of place from an IP lookup — city, region,
country, latitude, longitude — used to default the number system
and available to your app. Off by default: a page that needs no
geography makes no network call. Opt in with data-location on
the script tag (or data-location-endpoint for your own service).
facet.location is null until the promise resolves; failures are
silent. Precise GPS is never requested — that stays your app's
call, on a real user gesture.
Numbers grouped and spoken the way the reader expects:
facet.groupNumber(n) groups for the active locale — lakh/crore
digits where that is the norm, thousands elsewhere — and
facet.numberWords(n) reads a big number aloud ("7.43 crore",
"74.25 million", "7.43 करोड़"). facet.numberSystem() reports
which system resolved. Every helper takes a per-call override:
{system: "indian"} or {system: "western"}.
<script>
facet.groupNumber(7430000); // "74,30,000" or "7,430,000"
facet.numberWords(7430000); // "74.3 lakh" or "7.43 million"
facet.numberWords(7430000, { system: "western" }); // force one system
facet.numberSystem(); // "indian" | "western"
</script>
App shell & navigation
facet.views
The app shell behind main.view-stack: each child section is a
screen, one shows at a time, and navigation is hash-routed — a
plain <a href="#settings"> link changes screens, browser
Back walks the trail, and every screen is a URL an agent can
construct. facet.views is the programmatic handle: .go(id),
.back(), and .current. With JS off the sections render in
reading order, all visible.
Navigates to a URL with the page transition: the old page eases
away, the new one springs in, and the address changes normally.
The transition itself is opt-in markup — data-transition="page"
on the html tag or the script tag — and covers every same-origin
link automatically; facet.go(url) is for navigating from code
with the same feel. A link with data-no-transition snaps
instead. Browsers without view transitions simply navigate.
Full-page snap sections that survive iOS: facet.js upgrades a
.snap container of .snap-section children into a pager with
momentum, elastic overscroll and per-section scrolling for
sections taller than the screen — the machinery CSS scroll-snap
cannot deliver on an iPhone. It wires itself: give the container
the class and the pager exists. Links and tab segments carrying
data-section page to their target.
The edge-panel engine: open, close, scrim and state handling
for the app's sheets — settings from the right edge, navigation
from the left (.sheet-left). Add .sheet-rise and the same panel
becomes the tab bar's corner card instead: it condenses into
and rises out of its own trigger button (phones grow it up
from the bottom bar, wide screens drop it from the top bar's
corner), and the library's scroll thumb is injected on its
outer edge automatically. It self-wires — a button whose
aria-controls names a .sheet becomes its trigger, the
.sheet-scrim dims and closes on tap, and opening one panel
closes any other FIRST, then opens, so two panels never move
at once — most pages write no JS at all. Call facet.sheet(el, scrim) to wire one yourself; it
returns { open, close, toggle, isOpen }, and wired sheets expose
the same handle as element.facetSheet.
The phone header's menu engine. Tapping the fold icon grows
the link panel down from the header in one clean motion while
the page behind fades and blurs; tapping again, tapping
outside, or Escape shrinks it back and the scrim leaves with
it. Interrupting mid-motion continues from where it is.
Wired automatically to every details.top-menu-fold; with JS
off the fold still opens, just without the motion.
Upgrades every floating .nav-menu cluster into an overlay: a
dimming scrim, body scroll-lock, a staggered reveal, Escape to
close, and focus moved into the open stack and back out. The
menu itself is a real <details> wrapping real links, so it
works with JS off and its links sit in the DOM for crawlers.
Runs at load on its own; call facet.navMenu() again only after
inserting a nav-menu into the page late.
Injects the sliding pill behind a .tab-bar's active segment and
animates it on per-edge springs — the leading edge leads, the
trailing edge stretches, both settle with a small overshoot.
Every .tab-bar on the page gets this at load, wired to the snap
pager when one exists ([data-section] segments page there). A
scrolling pill starts with the current tab centred and stops
auto-centring the moment the reader scrolls it themselves; a
tapped link segment glides the highlight before the page
changes, and coming back through the back-forward cache puts
it back on the returning page's own tab.
facet.tabIndicator(bar) attaches it to a late-added bar and
returns { moveTo } for driving the pill yourself.
Attaches the slim themed scroll thumb to any scroller: thumb
height is the visible share of the content, position is
progress, and it fades out entirely when nothing overflows. It
returns { update } — call that after content changes so the
thumb resizes. Risen sheet panels get one automatically on
their outer edge, no call needed. A metric option reports composite scrolls (the
pager's sections laid end to end) and onScrub turns the groove
into a drag handle.
<script>
const gauge = facet.scrollGauge(document.querySelector(".sheet-scroll"));
gauge.update(); // after adding or removing content
</script>
Settings controls
The app's settings need no JS: any element carrying data-control
self-wires. Theme cycles the shipped themes, appearance cycles
light/dark/auto, motion cycles the motion modes, sounds and
haptics flip their channels — and each control's .menu-value
span shows the live state. Sheet menu rows with data-section
page the snap pager. This is the app-kit rule: apps are markup
plus data, the wiring is the library's job.
An input carrying data-number formats itself as you type:
digits group live per the active number system (12,34,567 or
1,234,567), a .number-words line under the field reads the
amount aloud in the active language, the example value clears
on first touch, and a .number-clear button empties it in one
tap. Grouping re-renders automatically when the language or
place changes.
The plain slider's one piece of wiring: an input.slider inside a
.field keeps its <output> in sync, so the number beside
the track always matches the thumb — including on load, when
the HTML and the value attribute can disagree. Everything else
about the slider is native range behaviour and theme CSS.
The detailed slider (.slider-detail) wires itself: coarse drag
on the track, −/+ steppers and a number box for the exact value,
all in sync, firing a bubbling facet:slide {value} on any
change. facet.sliderScale(wrap) is the one manual piece —
rebuild a .slider-wrap's tick scale after you change the
range's min, max or step from code.
<script>
const wrap = document.querySelector(".slider-wrap");
wrap.querySelector("input").max = 500;
facet.sliderScale(wrap); // redraw the ticks for the new range
document.addEventListener("facet:slide", (e) => {
console.log(e.detail.value);
});
</script>
Steppers
A .stepper's − and + buttons call the input's own stepUp and
stepDown, so the native min, max and step attributes rule, and
each bump fires input and change events as if the user had
typed. At the edges the matching button disables itself — the
state grammar's :disabled look, set live — and a feedback tick
marks each step.
Choice grids wire themselves — every .choice-btn in a
.choice-grid toggles aria-pressed as a radio group and fires a
bubbling facet:choice event with the pick. facet.choiceSelect
(grid, value) makes the pick from code — restoring a saved
answer, or building a wizard that pre-fills — and fires the same
event, so one listener serves both paths.
A button carrying data-copy="#id" copies that element's text to
the clipboard on click and confirms with the library's own
toast — never by relabelling the button, which would shift
layout and lose the button's name to assistive tech mid-press.
One attribute, no JS to write.
<pre id="snippet"><code>the text to take away</code></pre>
<button class="btn btn-small" data-copy="#snippet">Copy</button>
Filter inputs
A search input carrying data-filter="#target" live-hides the
target's children that do not match what is typed — the markup
is the data, nothing is re-rendered. It powers the filterable
card list and the data table's toolbar search, and works on any
container whose children are the rows.
A transient pill notice that announces itself politely and is
gone in four seconds — for outcomes that need no reply. The kind
sets the status dot: success, warning, error or info. One call,
no markup to prepare; the library owns the container and the
live region.
<script>
facet.toast("Report saved", "success");
facet.toast("Couldn't reach the server", "error");
</script>
facet.busy
The loading skin: set aria-busy="true" on any region and its
content skins into pulsing shape-tracing placeholders; remove it
and the content returns. facet.busy(el, true|false) does both
and upgrades text to per-line pills; slots carrying
data-skeleton-lines generate statistically text-shaped
placeholder words while empty. States are attributes, so the
busy look needs no JS to show — the call just sets it for you.
Draws the themed line chart into a host element: points with
labels, an optional projection (dashed from projectFrom on),
event markers pinned to the line, and a format option for the
value axis. It reads the theme's tokens, so the chart restyles
itself when the theme or mode changes.
The icon system's two handles: facet.icons is the glyph table —
every thin line icon by name — and facet.icon() fills every
svg[data-icon] on the page from it. Icons in the original markup
fill at load; call facet.icon() again after inserting markup, or
after adding your own glyphs to the table.
<svg data-icon="search" aria-hidden="true"></svg>
<script>
facet.icons.wave = '<path d="M2 12c3-4 5 4 8 0s5 4 8 0"/>';
facet.icon(); // fill new and late-added svgs
</script>
facet.mapStyle
Maps that wear the theme: a div carrying data-map becomes a
Google Map styled from the active theme's tokens, restyled live
when the theme or mode changes, centered on data-lat/data-lng or
the visitor's coarse location. The API key rides the script tag
(data-maps-key) or the div (data-map-key); without one the div
stays a themed placeholder. facet.mapStyle() returns the styles
array for wiring a map yourself.
In-page tabs wire themselves: inside a .tabs list, the buttons
carrying role="tab" get the full keyboard pattern — arrow keys
walk the set, Home and End jump, aria-selected moves — and each
tab shows the panel its aria-controls names while hiding its
siblings. No call to make; the roles in the markup are the
wiring.
A details.dropdown is a native disclosure that facet.js polishes
into a menu: clicking outside closes it, Escape closes it and
returns focus to the summary, and choosing any button or link in
the .dropdown-menu closes it too. Being a real <details>,
it opens and closes with JS off.
One attribute opens a modal: a button whose aria-controls names
a <dialog> shows it modally on click, with a tap on the
backdrop closing it. Escape and <form method="dialog">
buttons are the platform's own close paths — the library adds
the trigger and the outside-click, nothing more.
The tap half of the description tooltip. Hover and keyboard
focus are pure CSS; on touch screens facet.js opens the bubble
on tap instead — placed above, below or beside the element,
wherever there is room — and keeps it until the page scrolls,
a tap lands anywhere else, or the same element is tapped again. A control that opens something else (a sheet, a menu, a dialog) acts without pinning a bubble over what it opened.
Delegated, so late-added markup needs no wiring.
<button class="btn" data-tip="Runs the calculation">Calculate</button>
<!-- Desktop: hover or focus shows it. Touch: tap shows it,
scroll or a tap elsewhere hides it. Nothing to call. -->
Motion & app feel
facet.motion
The ambient motion engine: one vector spent on two channels.
The move channel translates registered elements (parallax);
the light channel steers registered elements'
--shine-x/--shine-y custom properties (shine). Three modes:
Off, Idle (the default — a slow self-animation, nothing
follows the device) and Responsive, which resolves itself to
the cursor on fine pointers or the device tilt on touch and
plays the idle loop whenever input is still. The choice
persists across visits; every change fires a facet:motion
event. Elements carrying data-parallax or data-shine register
themselves; facet.motion.register adds more. Reduced motion
collapses it all to nothing.
A specular light that travels with the device: mark any element
data-shine and a gleam tracks the motion vector across it —
cursor on desktop, tilt on a phone, a slow idle sweep otherwise.
The engine writes only the --shine-x/--shine-y custom
properties, never transform, so shine composes with any
component's own physics. One attribute, no call to make.
The background variants' JS halves. Glyph grids: an element
carrying data-bg-glyph draws its repeating character or icon
pattern and redraws itself when the glyph, size, theme or mode
changes — facet.glyphBackground(el) forces one redraw by hand.
The scatter: data-bg-scatter on a technical, dot or circle
grid sows your characters across the lattice, replacing one
point in ten (data-bg-scatter-rate tunes the frequency,
data-bg-scatter-scale the character size), in a fresh
arrangement every page load — facet.scatterBackground(el)
forces one redraw by hand. Fluid: data-bg-fluid mounts the
drifting bokeh layers at load — facet.fluidBackground(el)
mounts a surface added later. All engines are otherwise
hands-off.
Synthesized UI sound and vibration with no audio files: short
Web Audio blips plus navigator.vibrate haptics, both on by
default and already wired into the components — a tick as a
stepper bumps, a soft snap as a pager settles. Fire it yourself
with .tap(), .tick(), .snap() or .success(); mute a channel by
setting .sound.enabled or .haptic.enabled to false (the settings
sheet's data-control="sounds"/"haptics" toggles do exactly
that). A mute holds for the session — it sticks across page
navigations and resets when the tab closes.
<script>
facet.feedback.success(); // after a save lands
facet.feedback.sound.enabled = false;
facet.feedback.haptic.enabled = true;
</script>
facet.install & PWA
The install machinery: data-service-worker="/sw.js" on the
script tag registers your one-line worker stub (which loads the
library's caching engine), buttons carrying data-install stay
hidden until the browser offers installation and then show the
real prompt, and facet.install() does the same from code.
facet.installNudge(el) runs the add-to-home-screen flow on a
.nudge-scrim and returns { standalone, installed, addNow,
never }.
<script src=".../lib/facet.js" data-service-worker="/sw.js" defer></script>
<button class="btn btn-primary" data-install hidden>Install the app</button>
<script>
facet.install(); // the same prompt, from code
</script>
Print wiring
The JS half of the print system: before printing, every closed
fold and accordion opens (a <details> cannot be opened
from CSS alone) and lazy images load so nothing prints blank;
after printing, exactly what was opened closes again. Any button
carrying data-print-action="page" prints the page — the
documented Save-as-PDF affordance. Everything else about paper
(roles, ink, page breaks) is the CSS print stylesheet's job.
<button class="btn" data-print-action="page" data-event="export-pdf">
Save as PDF
</button>