The function library

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.

Configure

facet.set

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.

<script>
  facet.set({ theme: "velvet" });
  facet.set({ mode: "dark" });
  facet.set({ "--accent-1": "#0F6FDE" });   // one token, this visit
  facet.set({ accent1: "#0F6FDE" });        // whole rank, remembered
  facet.set({ ink: "#1A2E1A", fontHeading: "Playfair Display" });
  facet.set({ accent1: c, accent3: c }, { persist: false });  // now, not remembered
</script>

Script-tag boot

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.

<script src="https://facet-kappa.vercel.app/lib/facet.js"
        data-theme="aero" data-mode="dark" data-transition="page" defer></script>

facet.scheme

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.

<script src=".../lib/facet.js" data-location defer></script>

<script>
  facet.locate().then(() => {
    if (facet.location) console.log(facet.location.city);
  });
</script>

Number helpers

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.

<main class="view-stack">
  <section id="home">…</section>
  <section id="settings">…</section>
</main>

<script>
  facet.views.go("settings");
  facet.views.back();
  facet.views.current;   // "settings"
</script>

facet.go

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.

<html data-transition="page">

<script>
  facet.go("/settings.html");
</script>

Snap pager

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.

<main class="snap">
  <section class="snap-section" id="one">…</section>
  <section class="snap-section" id="two">…</section>
</main>

<button class="tab-seg" data-section="two">Two</button>

facet.sheet

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.

<button class="btn" aria-controls="menu" aria-expanded="false">Menu</button>
<div class="sheet-scrim" data-open="false"></div>
<aside class="sheet" id="menu" data-open="false" aria-hidden="true">…</aside>

<script>
  document.getElementById("menu").facetSheet.open();
</script>

Top menu fold

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.

<details class="top-menu-fold">
  <summary class="fold-trigger" aria-label="Menu">
    <svg width="20" height="20" data-icon="menu" class="when-closed" aria-hidden="true"></svg>
    <svg width="20" height="20" data-icon="close" class="when-open" aria-hidden="true"></svg>
  </summary>
  <nav class="top-menu-links" aria-label="Site">…</nav>
</details>

<!-- Nothing to call — the engine owns every details.top-menu-fold. -->

facet.navMenu

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.

<nav class="nav-menu" aria-label="Site" data-print="off">
  <details class="nav-menu-panel">
    <summary class="btn btn-pill">Menu</summary>
    <div class="nav-menu-items">
      <a class="btn btn-pill" href="/">Home</a>
    </div>
  </details>
</nav>

facet.tabIndicator

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.

<nav class="tab-bar" aria-label="Sections" data-print="off">
  <button class="tab-seg" aria-current="page" data-section="home">Home</button>
  <button class="tab-seg" data-section="stats">Stats</button>
</nav>

<script>
  const bar = facet.tabIndicator(document.querySelector(".tab-bar"));
  bar.moveTo(1);
</script>

facet.scrollGauge

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.

<button type="button" class="menu-item" data-control="theme">
  Theme <span class="menu-value">Default</span>
</button>
<button type="button" class="menu-item" data-control="sounds">
  Sounds <span class="menu-value">On</span>
</button>
<button type="button" class="menu-item" data-section="stats">Open stats</button>

Forms & inputs

Number inputs

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.

<label class="field">
  Amount
  <span class="number-wrap">
    <input type="text" inputmode="numeric" data-number value="1250000">
    <button type="button" class="number-clear" aria-label="Clear"></button>
  </span>
  <span class="number-words"></span>
</label>

Sliders

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.

<label class="field">
  Volume <output>40</output>
  <input type="range" class="slider" min="0" max="100" value="40">
</label>

facet.sliderScale

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.

<div class="stepper">
  <button type="button" class="stepper-down" aria-label="Fewer">−</button>
  <input type="number" value="2" min="1" max="9" aria-label="Guests">
  <button type="button" class="stepper-up" aria-label="More">+</button>
</div>

facet.choiceSelect

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.

<script>
  const grid = document.querySelector(".choice-grid");
  facet.choiceSelect(grid, "monthly");

  grid.addEventListener("facet:choice", (e) => {
    console.log(e.detail.value);
  });
</script>

Copy buttons

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.

<input type="search" placeholder="Filter projects" data-filter="#projects">
<ul class="list" id="projects">
  <li>Alpha</li>
  <li>Beacon</li>
</ul>

Content & status

facet.toast

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.

<section id="report" aria-busy="true">…</section>

<script>
  const region = document.querySelector("#report");
  facet.busy(region, true);
  await load();
  facet.busy(region, false);
</script>

facet.chart

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.

<div id="growth"></div>

<script>
  facet.chart(document.querySelector("#growth"), {
    points: [{ x: 2024, y: 120, label: "2024" },
             { x: 2025, y: 180, label: "2025" },
             { x: 2026, y: 260, label: "2026" }],
    projectFrom: 2,
    events: [{ x: 2025, label: "Launch" }],
  });
</script>

facet.icon

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.

<div data-map data-lat="28.61" data-lng="77.21" style="height: 20rem;"></div>

<script>
  new google.maps.Map(el, { styles: facet.mapStyle() });
</script>

Tabs

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.

<div class="tabs" role="tablist" aria-label="Report">
  <button role="tab" aria-selected="true" aria-controls="panel-summary">Summary</button>
  <button role="tab" aria-selected="false" aria-controls="panel-detail">Detail</button>
</div>
<div id="panel-summary" role="tabpanel">…</div>
<div id="panel-detail" role="tabpanel" hidden>…</div>

Dropdowns

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.

<details class="dropdown">
  <summary class="btn">Actions</summary>
  <div class="dropdown-menu">
    <button type="button">Rename</button>
    <button type="button">Duplicate</button>
  </div>
</details>

Dialog triggers

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.

<button class="btn btn-primary" aria-controls="confirm">Delete…</button>

<dialog id="confirm" class="modal">
  <p>Delete this report?</p>
  <form method="dialog">
    <button class="btn">Cancel</button>
    <button class="btn btn-primary" value="yes">Delete</button>
  </form>
</dialog>

Touch tooltips

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.

<img src="hero.png" data-parallax alt="">

<script>
  facet.motion.register(".hero-art", { xMax: 12, yMax: 8 });
  facet.motion.register(".card", { light: true });
  facet.motion.set("responsive");   // "off" | "idle" | "responsive"
  facet.motion.label();             // "Off" | "Idle" | "Cursor" | "Tilt"
</script>

Shine (data-shine)

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.

<span class="icon-badge" data-shine>
  <svg data-icon="star" aria-hidden="true"></svg>
</span>

<button class="btn btn-primary" data-shine>Get started</button>

Background engines

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.

<section data-bg-glyph="sparkle" data-bg-glyph-size="28">…</section>
<section class="bg-dots" data-bg-scatter="✦ ❋" data-bg-scatter-rate="10">…</section>
<section data-bg-fluid>…</section>

<script>
  facet.glyphBackground(document.querySelector("[data-bg-glyph]"));
  facet.scatterBackground(document.querySelector("[data-bg-scatter]"));
  facet.fluidBackground(lateAddedSection);
</script>

facet.feedback

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>