--- title: "Introduction" description: "HTMX UI is a set of Tailwind CSS components for apps where the server renders HTML and htmx swaps it into the page." url: "https://susonwaiba.github.io/htmx-ui/docs" section: "Getting started" --- # Introduction HTMX UI is a set of Tailwind CSS components for apps where the server renders HTML and htmx swaps it into the page. - [Installation](https://susonwaiba.github.io/htmx-ui/docs/installation): Add the package to a new or existing Bun project. - [Theming](https://susonwaiba.github.io/htmx-ui/docs/theming): Override design tokens or build a custom theme. - [Dark mode](https://susonwaiba.github.io/htmx-ui/docs/dark-mode): How dark mode works and how to add a switcher. - [Components](https://susonwaiba.github.io/htmx-ui/docs/components): Browse every component with live examples. - [Upgrading](https://susonwaiba.github.io/htmx-ui/docs/upgrading): One command rewrites what a release renamed. ## What it is Most component libraries assume a JavaScript framework owns the page. HTMX UI assumes the opposite: your server returns HTML, htmx swaps it in, and components need to look right and keep working when they arrive inside a fragment. So every component is: - **Markup and classes.** A button is ` ``` ## Working with htmx The entry script calls `initComponents(document)` once the page loads, and again with each newly inserted element on the `htmx:after:process` event. Each initialiser only touches elements it hasn't seen yet (it marks them with `data-init`), so a fragment's components come alive without anything running twice. ```ts app.ts import "htmx.org"; import { initComponents } from "htmx-ui"; document.addEventListener("DOMContentLoaded", () => initComponents(document)); document.addEventListener("htmx:after:process", (e) => initComponents(e.target ?? document)); ``` > **Why not htmx:after:swap?** In htmx 4, `htmx:after:swap` is dispatched on the element that made the request, so its target is not the new content. `htmx:after:process` fires on each element htmx inserts, which is exactly what needs initialising. ## Upgrading Before 1.0 a minor release can rename a class or change a component's markup, and every one of them is listed under _Breaking changes_ in the [changelog](https://susonwaiba.github.io/htmx-ui/docs/changelog). You don't make those edits by hand: `htmx-ui-upgrade` rewrites your templates, scripts, stylesheets and config to the release you are moving to, then prints the places it left for you to check. It refuses to run on uncommitted changes, so an upgrade is one diff to review — or to undo. See [Upgrading](https://susonwaiba.github.io/htmx-ui/docs/upgrading). ## What it's built on - [htmx 4](https://htmx.org) for requests and swaps. - [Tailwind CSS v4](https://tailwindcss.com) for styling and tokens. - [Bun](https://bun.sh) for bundling, the dev server and tests. - [Nunjucks](https://mozilla.github.io/nunjucks/) macros (optional) for less repetitive markup. --- title: "Installation" description: "Start a new HTMX UI site with one command, or add the components to an app that already uses Tailwind CSS v4 and htmx." url: "/docs/installation" section: "Getting started" --- # Installation Start a new HTMX UI site with one command, or add the components to an app that already uses Tailwind CSS v4 and htmx. Commands are shown for bun, npm, pnpm and yarn. Pick yours once; every command block on the site remembers the choice. ## Packages | Package | What it is | | --- | --- | | `htmx-ui` | The components: CSS classes, design tokens, dark mode and small behaviours. Works with any bundler. | | `htmx-ui-engine` | Builds a site from Nunjucks pages: `htmx-ui dev`, `build` and `preview`, on Bun or Node. See [Engine](https://susonwaiba.github.io/htmx-ui/docs/engine). | | `create-htmx-ui` | Scaffolds a new site that uses both. | | `htmx-ui-upgrade` | Not installed with the project: run it when you update, and it rewrites what the release changed. See [Upgrading](https://susonwaiba.github.io/htmx-ui/docs/upgrading). | ## Requirements | Dependency | Version | Why | | --- | --- | --- | | [Tailwind CSS](https://tailwindcss.com) | 4.x | Component styles use `@apply` and `@theme`. | | [htmx](https://htmx.org) | 4.x | Fragment swaps; components initialise inside new content. | | [Bun](https://bun.sh) or [Node](https://nodejs.org) | Bun 1.2.3+ / Node 22.12+ | For the engine. With your own bundler, any that handles TypeScript and CSS imports works. | ## New project Create a site, install it and start the dev server: ```bash bun bun create htmx-ui my-site cd my-site bun install bun run dev ``` ```bash npm npm create htmx-ui my-site cd my-site npm install npm run dev ``` ```bash pnpm pnpm create htmx-ui my-site cd my-site pnpm install pnpm run dev ``` ```bash yarn yarn create htmx-ui my-site cd my-site yarn install yarn run dev ``` You get `pages/` (one file per route), a layout with a theme toggle, Tailwind with the library's styles, an htmx button talking to a mock endpoint, and `dev`, `build` and `preview` scripts. A site created with bun runs on Bun; with npm, pnpm or yarn it runs on Node, with Vite. Open `http://localhost:3000` and edit `pages/index.html`. ## Add the engine to a project ### 1. Install ```bash bun bun add htmx-ui htmx.org bun add -d htmx-ui-engine tailwindcss ``` ```bash npm npm install htmx-ui htmx.org npm install -D htmx-ui-engine tailwindcss ``` ```bash pnpm pnpm add htmx-ui htmx.org pnpm add -D htmx-ui-engine tailwindcss ``` ```bash yarn yarn add htmx-ui htmx.org yarn add -D htmx-ui-engine tailwindcss ``` Then the Tailwind integration for the runtime you'll build with. On Bun: ```bash Terminal bun add -d bun-plugin-tailwind ``` On Node (npm, pnpm or yarn), Vite and its Tailwind plugin: ```bash bun bun add -d vite @tailwindcss/vite ``` ```bash npm npm install -D vite @tailwindcss/vite ``` ```bash pnpm pnpm add -D vite @tailwindcss/vite ``` ```bash yarn yarn add -D vite @tailwindcss/vite ``` ### 2. Add the scripts ```json package.json { "scripts": { "dev": "htmx-ui dev", "build": "htmx-ui build", "preview": "htmx-ui preview" } } ``` ### 3. Import the styles Import Tailwind first, then the library. The `@source` line tells Tailwind where your own markup lives. ```css styles.css @import "tailwindcss"; @import "htmx-ui/styles.css"; @source "./"; ``` ### 4. Initialise components ```ts app.ts import "htmx.org"; import "./styles.css"; import { initComponents } from "htmx-ui"; import { initTheme } from "htmx-ui/theme"; document.addEventListener("DOMContentLoaded", () => { initTheme(); initComponents(document); }); // htmx 4 fires htmx:after:process on each newly inserted element document.addEventListener("htmx:after:process", (e) => { initComponents((e.target as ParentNode) ?? document); }); ``` ### 5. Add a layout and a page `asset()` makes the script path relative to whichever page is rendering, so the bundler finds it from any depth. ```jinja layouts/base.html {% block title %}My site{% endblock %} {% block content %}{% endblock %} ``` ```jinja pages/index.html {% extends "layouts/base.html" %} {% block content %}{% endblock %} ``` ```bash bun bun run dev ``` ```bash npm npm run dev ``` ```bash pnpm pnpm run dev ``` ```bash yarn yarn run dev ``` ## Existing project If the app already has Tailwind v4, htmx and a bundler, it only needs the components: ```bash bun bun add htmx-ui ``` ```bash npm npm install htmx-ui ``` ```bash pnpm pnpm add htmx-ui ``` ```bash yarn yarn add htmx-ui ``` 1. Add `@import "htmx-ui/styles.css";` directly after `@import "tailwindcss";`. 2. Call `initComponents()` on load and on `htmx:after:process`, as in step 4 above. > **Clashing variable names** The library defines CSS variables such as `--primary`, `--border` and `--radius` on `:root`, and Tailwind colours with the same names. If your app already defines these, the definition loaded last wins. Rename yours, or set them to the values you want (see [Theming](https://susonwaiba.github.io/htmx-ui/docs/theming)). ### Already using the dark: variant? The library switches Tailwind's `dark:` variant from the OS media query to a `.dark` class on ``. If your app relies on the media-query behaviour, read [Dark mode](https://susonwaiba.github.io/htmx-ui/docs/dark-mode) before upgrading. ## Upgrading later A release before 1.0 can rename a class or change a component's markup — each one listed under _Breaking changes_ in the [changelog](https://susonwaiba.github.io/htmx-ui/docs/changelog). You don't have to apply them by hand: `htmx-ui-upgrade` rewrites your templates, scripts, stylesheets and config to the release you are moving to, and prints the places it left for you to check. It refuses to run on uncommitted changes, so an upgrade is one diff to review: ```bash bun bunx htmx-ui-upgrade@latest ``` ```bash npm npx htmx-ui-upgrade@latest ``` ```bash pnpm pnpm dlx htmx-ui-upgrade@latest ``` ```bash yarn yarn dlx htmx-ui-upgrade@latest ``` Then install the new versions and build. What it changes, its options and how to check the result are on [Upgrading](https://susonwaiba.github.io/htmx-ui/docs/upgrading). ## What the package exports JavaScript ships as ES modules with TypeScript types. `htmx.org` and `tailwindcss` are peer dependencies, so your app controls their versions. | Import | Contents | | --- | --- | | `htmx-ui` | `initComponents(root)`: wires up every component's behaviour under `root`. | | `htmx-ui/styles.css` | Design tokens, the dark variant, `.prose` (Tailwind Typography, installed with the package), syntax-highlighting colours and every component's styles. | | `htmx-ui/theme` | `initTheme()` and `getTheme()` for the light/dark switcher. | | `htmx-ui/icons/.svg` | The SVG icons, for your own templates or `` tags. | | `htmx-ui/components//.html` | Nunjucks macros (icon, code, alert, spinner). The engine resolves them by name: `{% from "components/icon/icon.html" import icon %}`. | --- title: "Engine" description: "htmx-ui-engine builds a site from Nunjucks pages: file-based routing, Tailwind, a dev server with hot reload and mock htmx endpoints, and static builds, on Bun or Node." url: "/docs/engine" section: "Getting started" --- # Engine htmx-ui-engine builds a site from Nunjucks pages: file-based routing, Tailwind, a dev server with hot reload and mock htmx endpoints, and static builds, on Bun or Node. `htmx-ui-engine` is the build tool behind `bun create htmx-ui` and this website. It turns a folder of HTML pages into a static site: each page is rendered with Nunjucks, then bundled (scripts and styles hashed and minified, Tailwind compiled from every class the page uses). The same engine runs on two runtimes: | Runtime | Dev server | Build | Tailwind | | --- | --- | --- | --- | | Bun | `Bun.serve` with HMR | `Bun.build` | `bun-plugin-tailwind` | | Node | Vite with HMR | Vite | `@tailwindcss/vite` | Both render pages with the same code, so a site builds the same way on either. ## Commands ```json package.json { "scripts": { "dev": "htmx-ui dev", "build": "htmx-ui build", "preview": "htmx-ui preview" } } ``` | Command / option | Description | | --- | --- | | `htmx-ui dev` | Dev server at http://localhost:3000 with hot reload: scripts and styles update in place; pages reload when a layout, partial, macro or data file changes. | | `htmx-ui build` | Builds every page into dist/ (same paths as pages/), with scripts, styles and images in dist/assets/, content-hashed and shared by every page that uses them. Copies public/ next to it. | | `htmx-ui preview` | Serves dist/ with the same clean URLs (/about for about.html). | | `--port ` | Port for dev and preview. $PORT works too. | | `--root ` | Project root, if not the current directory. | | `--bun, --node` | Pick the runtime (also HTMX_UI_RUNTIME=bun\|node). | The runtime follows whatever started the command: `bun run dev` and `bunx htmx-ui` run on Bun; `npm run`, `pnpm`, `yarn` and `npx` run on Node. Install the Tailwind integration for the one you use: ```bash Bun bun add -d htmx-ui-engine tailwindcss bun-plugin-tailwind ``` ```bash Node npm install -D htmx-ui-engine tailwindcss vite @tailwindcss/vite ``` ## Project layout ```text my-site/ pages/index.html -> / pages/about.html -> /about pages/docs/index.html -> /docs pages/docs/setup.html -> /docs/setup layouts/ partials/ shared templates data/*.json read from templates with json() public/ served at / and copied to dist/ as-is app.ts styles.css client entry and stylesheet htmx-ui.config.ts optional ``` Pages, layouts and partials are covered in [Templates & data](https://susonwaiba.github.io/htmx-ui/docs/templates). With `htmx-ui` installed, its macros and icons resolve by name, after your own files: `{% from "components/icon/icon.html" import icon %}`. Link files in `public/` with a root-absolute URL (`href="/favicon.svg"`); they keep their names. Link everything else with `asset()`, so the bundler can hash it. ## Configuration Every option is optional, and a project without `htmx-ui.config.ts` uses the defaults. ```ts htmx-ui.config.ts import { defineConfig } from "htmx-ui-engine"; export default defineConfig({ roots: ["."], pages: "pages", outDir: "dist", publicDir: "public", url: "https://example.com", port: 3000, }); ``` | Option | Description | | --- | --- | | `pages` | Routes directory. Default "pages". | | `roots` | Where templates, data, icons and assets are found, searched in order. A directory, or one with a name. Default ["."]; htmx-ui's src/ is added last (ui: false turns that off). See Roots below. | | `render` | This deployment renders templates at runtime. createSite() then checks at startup that the roots and pages are really there, and says what to copy if not. Default false. | | `outDir` | Build output. Default "dist". | | `publicDir` | Copied as-is. Default "public"; false to disable. | | `url` | Public site URL, the origin template global. $SITE_URL overrides it. | | `port` | Dev and preview port. Default 3000; $PORT overrides it. | | `debug` | Log what the engine is doing: requests, renders, template compiles and builds. true, or a list of topics. Off by default (see below). | | `globals, filters` | Extra Nunjucks globals and filters u2014 your own template helpers, on top of the built-in ones. | | `transform(html, page)` | Post-process every rendered page, in dev and build. | | `routes` | Request handlers for mock endpoints (see below). | | `fetch(req)` | Fallback for anything else; return null for a 404. | | `build.minify, build.sourcemap, build.define` | Production bundling options. | | `build.done(ctx)` | Runs after a build with { outDir, pages }, e.g. to write a sitemap. | | `vite` | Node only: extra Vite config, merged over the engine's. | | `plugins` | Optional features, such as docs, versions and search: [docs(), search()]. See Plugins below. | `globals` and `filters` are where a project adds its own template helpers; see [custom globals and filters](https://susonwaiba.github.io/htmx-ui/docs/templates#custom). ## Roots `roots` is every directory templates, data, icons and assets are found in, searched in order. It defaults to `[\".\"]`, the project, which is why nothing has to configure it to get started. The **first entry is the project root**: `pages`, `outDir`, `publicDir` and `asset()` URLs are all measured from it, and it is the Vite root and Bun's working directory. ### Naming a directory An entry can carry a **name**, which is the prefix templates reach that directory by. Give one to a directory that is likely to move, and the move stops reaching your templates — rename the directory and the name still resolves it: ```ts htmx-ui.config.ts export default defineConfig({ roots: [ ".", { name: "layouts", dir: "new-layouts-2026" }, { name: "acme", dir: "../acme-ui" }, ], }); ``` The layout in `new-layouts-2026/` is still reached as `layouts/`, and every page keeps saying so: ```jinja pages/index.html {% extends "layouts/base.html" %}{% endblock %} ``` A name is a single directory name, not a path, and no two roots may share one — both are refused at startup rather than resolved by luck of the order. The name works everywhere a template name does: `extends`, `include`, `import`, `{% from %}`, `json()`, `svg()`, `glob()` and `asset()`. > **Names are part of the template namespace** `name` takes that directory's own files. With `{ name: "layouts", dir: "new-layouts-2026" }`, a page asks for `layouts/base.html`, never `layouts/new-layouts-2026/base.html`. If two roots are inside one another, list the more specific one first: it is searched first, and it wins. ### Moving a directory between dev and production A deployment that renders fragments needs its templates on disk, and `htmx-ui build` does not copy them. That is a change to `roots` and nothing else — the templates are not edited, and no reference to them moves: ```ts htmx-ui.config.ts in the deployment export default defineConfig({ render: true, roots: [".", "dist/_templates"], // "." first: the first root is the project root pages: "dist/_templates/pages", }); ``` See [static pages, dynamic fragments](https://susonwaiba.github.io/htmx-ui/docs/servers/deploying#static-and-dynamic) for the copy step, and `render` for the startup check that says what is missing. ### Watching The dev server watches the roots and nothing else, and always skips `node_modules/`, `dist/` and `public/`. A project whose roots are its own directories therefore never walks its dependencies to look for templates that are not there. The trade is that a change under a skipped directory no longer reloads the page: a new public asset appears on the next refresh rather than immediately. On Bun it also watches the packages linked into the project (workspace packages, `file:` and `link:` dependencies) for stylesheets, since one that a Tailwind stylesheet `@import`s is invisible to Bun's own hot reloading. And because Bun only reliably notices edits under the directory it runs from, `htmx-ui dev` runs the server from the directory that holds the project and its linked packages, so editing a linked package's scripts updates the page too. `templates` is the older name for the same list of directories and still works, but it does not move the project root and cannot carry names — prefer `roots`. ## Mock htmx endpoints While you build the front end, `routes` answers `hx-get` and `hx-post` with HTML fragments, so no backend is needed. Handlers take a web `Request` and return a `Response`, with Bun.serve's route syntax, on either runtime: ```ts htmx-ui.config.ts import { defineConfig } from "htmx-ui-engine"; const html = (body: string) => new Response(body, { headers: { "Content-Type": "text/html" } }); export default defineConfig({ routes: { "/api/users/:id": (req) => html(`

User ${req.params.id}

`), "/api/search": async (req) => { const q = new URL(req.url).searchParams.get("q") ?? ""; return html(`
  • Results for ${q}
  • `); }, }, }); ``` > **Dev server and your own server** `htmx-ui build` is static, so a static host never sees these routes. Mount the site on a backend with [a server adapter](https://susonwaiba.github.io/htmx-ui/docs/servers) and they are served there too, by your own process, so [leave mocks out of production](https://susonwaiba.github.io/htmx-ui/docs/servers/deploying#mock-routes). ## Debugging Set `debug` in `htmx-ui.config.ts` and the engine logs what it is doing: which requests arrive, what answers them, how long every render took, and which templates it had to compile. It is off by default, so it costs nothing in production. ```ts htmx-ui.config.ts import { defineConfig } from "htmx-ui-engine"; export default defineConfig({ debug: true, }); ``` Every request is logged twice: once when it arrives, once when it is answered, with where the answer came from. That pair is the point. A request whose first line never gets a second one is the request still in flight — which is what a page that never finishes loading looks like from the server: ```txt Terminal [htmx-ui] GET /api/team/7 [htmx-ui] GET /api/team/7 200 via route /api/team/:id 0.0ms [htmx-ui] GET /nope [htmx-ui] GET /nope 404 via 404.html 0.2ms ``` Where the answer came from is one of: a pattern from `routes`, `dist` for a built file, `page` for a page rendered from its template in development, `fetch` for the config's fallback, then a 404: `404.html` from the build, `pages/404.html` in development, or `default 404` for htmx-ui's own page. Narrow the output to what you are looking at by listing topics instead: `requests`, `render`, `templates` and `build`. ```ts htmx-ui.config.ts export default defineConfig({ debug: ["requests", "render"], }); ``` `render` reports every `site.render()` and `site.fragment()` with its output size and duration. `templates` reports the template cache filling up: one line per template as it compiles, then the warm-up that `createSite()` does at startup, so nothing compiles on your first request. Any step over a second is also marked `SLOW`. ```txt Terminal [htmx-ui] compiled pages/docs/components/button.html [htmx-ui] warm: compiled 56 of 56 templates from 42 files in 61.2ms [htmx-ui] render /docs/components/button done 8.4KB 10.6ms cached=true [htmx-ui] fragment partials/row.html done 0.2KB 0.4ms ``` ## Plugins Features only some sites need are plugins: separate packages listed in `plugins`, which add templates, routes, build output and commands. The official ones give a site [docs](https://susonwaiba.github.io/htmx-ui/docs/plugins/docs) with Markdown and llms.txt, [versioned docs](https://susonwaiba.github.io/htmx-ui/docs/plugins/versions) and [search](https://susonwaiba.github.io/htmx-ui/docs/plugins/search): ```ts htmx-ui.config.ts import { defineConfig } from "htmx-ui-engine"; import docs from "htmx-ui-plugin-docs"; import search from "htmx-ui-plugin-search"; export default defineConfig({ plugins: [docs(), search()], }); ``` See [Plugins](https://susonwaiba.github.io/htmx-ui/docs/plugins), and [Writing a plugin](https://susonwaiba.github.io/htmx-ui/docs/plugins/writing) to make your own. ## Serving it from a backend A built site is just files, so any server can host it. To put the site and your own API in one process, the engine has adapters for [Elysia](https://susonwaiba.github.io/htmx-ui/docs/servers/elysia), [Fastify](https://susonwaiba.github.io/htmx-ui/docs/servers/fastify), [Koa](https://susonwaiba.github.io/htmx-ui/docs/servers/koa), [Express](https://susonwaiba.github.io/htmx-ui/docs/servers/express) and [Hono](https://susonwaiba.github.io/htmx-ui/docs/servers/hono), and `createSite()` for anything else. The same templates also render the fragments your API returns to htmx: ```ts server.ts import { createSite } from "htmx-ui-engine"; const site = await createSite(); Bun.serve({ fetch: (request) => site.handle(request) }); // pages, assets, routes, a 404 page ``` See [Server frameworks](https://susonwaiba.github.io/htmx-ui/docs/servers). ## Use the bundler plugins directly Already have a build script or a Vite config? Use the engine's Bun or Vite plugin and skip the CLI: ```ts build.ts (Bun) import { htmxUi } from "htmx-ui-engine/bun"; import tailwind from "bun-plugin-tailwind"; await Bun.build({ entrypoints: ["./pages/index.html", "./pages/about.html"], root: "./pages", outdir: "./dist", plugins: [await htmxUi(), tailwind], // htmx-ui first, so Tailwind sees rendered markup }); ``` ```ts vite.config.ts (Node) import { htmxUi } from "htmx-ui-engine/vite"; import tailwindcss from "@tailwindcss/vite"; export default { plugins: [htmxUi(), tailwindcss()], }; ``` The package root, `htmx-ui-engine`, exports the runtime-independent core for your own tooling: `defineConfig`, `loadConfig`, `render`, `renderPage`, `findPages`, `routeFor` and `highlight`, plus `definePlugin`, `editHtml` and `contentType` for writing plugins. --- title: "Theming" description: "Every colour and corner in HTMX UI comes from a CSS variable. Built-in themes restyle the library in one import; override the variables to go further, and scope them to restyle part of a page." url: "/docs/theming" section: "Customization" --- # Theming Every colour and corner in HTMX UI comes from a CSS variable. Built-in themes restyle the library in one import; override the variables to go further, and scope them to restyle part of a page. ## How it works Theming happens in two layers, both in `htmx-ui/styles.css`: 1. **Variables.** Plain custom properties on `:root` hold the actual values, with a second set under `:root.dark`. 2. **Tailwind colours.** A `@theme inline` block maps each variable to a Tailwind colour, so `--primary` becomes `bg-primary`, `text-primary`, `border-primary/30` and so on. ```css htmx-ui/styles.css (excerpt) :root { --radius: 0.5rem; --primary: var(--color-indigo-600); --primary-foreground: var(--color-white); /* … */ } :root.dark { --primary: var(--color-indigo-500); /* … */ } @theme inline { --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); /* … */ } ``` Because the mapping is `inline`, the generated utilities read the variable at runtime: `.bg-primary` compiles to `background-color: var(--primary)`. Change the variable anywhere (globally, under `.dark`, or on one element) and everything using it updates. No rebuild needed. ## Tokens Components only use these tokens. Swatches show the current theme; toggle dark mode in the header to compare. | Variable | Utility | Used for | Light | Dark | | --- | --- | --- | --- | --- | | `--background` | `bg-background` | Page background | white | zinc-950 | | `--foreground` | `text-foreground` | Default text | zinc-950 | zinc-50 | | `--surface` | `bg-surface` | Cards, alerts, raised panels | white | zinc-900 | | `--muted` | `bg-muted` | Subtle fills: code, table heads | zinc-100 | zinc-800 | | `--muted-foreground` | `text-muted-foreground` | Secondary text | zinc-500 | zinc-400 | | `--accent` | `bg-accent` | Hover and selected backgrounds | zinc-100 | zinc-800 | | `--accent-foreground` | `text-accent-foreground` | Text on accent | zinc-900 | zinc-50 | | `--border` | `border-border` | Borders and dividers (the default border colour) | zinc-200 | zinc-800 | | `--ring` | `outline-ring` | Focus rings | indigo-500 | indigo-400 | | `--primary` | `bg-primary` | Brand colour: primary buttons, links | indigo-600 | indigo-500 | | `--primary-foreground` | `text-primary-foreground` | Text on primary | white | white | | `--secondary` | `bg-secondary` | Secondary buttons and badges | zinc-100 | zinc-800 | | `--secondary-foreground` | `text-secondary-foreground` | Text on secondary | zinc-900 | zinc-50 | | `--info` | `text-info` | Info alerts and badges | sky-600 | sky-400 | | `--success` | `text-success` | Success alerts and badges | emerald-600 | emerald-400 | | `--warning` | `text-warning` | Warning alerts and badges | amber-600 | amber-400 | | `--danger` | `bg-danger` | Destructive buttons, error alerts | red-600 | red-500 | | `--danger-foreground` | `text-danger-foreground` | Text on danger | white | white | | `--sidebar` | `bg-sidebar` | Sidebar background | zinc-50 | zinc-900 | | `--sidebar-foreground` | `text-sidebar-foreground` | Sidebar text and icons | --foreground | --foreground | | `--sidebar-primary` | `bg-sidebar-primary` | Accent fills in the sidebar, such as a logo tile | --primary | --primary | | `--sidebar-primary-foreground` | `text-sidebar-primary-foreground` | Text on sidebar primary | --primary-foreground | --primary-foreground | | `--sidebar-accent` | `bg-sidebar-accent` | Hovered and current sidebar items | --accent | --accent | | `--sidebar-accent-foreground` | `text-sidebar-accent-foreground` | Text on sidebar accent | --accent-foreground | --accent-foreground | | `--sidebar-border` | `border-sidebar-border` | Sidebar borders, separators and the rail | --border | --border | | `--sidebar-ring` | `ring-sidebar-ring` | Focus rings in the sidebar | --ring | --ring | | `--scrollbar-thumb` | `(scrollbars)` | Scrollbar thumb; every scrollbar is thin with a transparent track | foreground at 16% | foreground at 16% | | `--scrollbar-thumb-hover` | `(scrollbars)` | Scrollbar thumb while hovering the scroll area | foreground at 32% | foreground at 32% | | `--radius` | `rounded-(--radius)` | Corner radius for buttons, inputs, alerts; cards add 4px | 0.5rem | 0.5rem | Values are Tailwind palette colours, for example `var(--color-indigo-600)`. Any CSS colour works: hex, `rgb()`, `oklch()`. The `--sidebar-*` tokens colour the [Sidebar](https://susonwaiba.github.io/htmx-ui/docs/components/sidebar), so it can be tinted apart from the page. Apart from `--sidebar`, they follow the token named in the table unless you set them ([Sidebar theming](https://susonwaiba.github.io/htmx-ui/docs/components/sidebar#theming)). ## Overriding tokens Redefine variables after importing the library. Set both the light and dark values for anything that should differ between them. ```css src/styles.css @import "tailwindcss"; @import "htmx-ui/styles.css"; :root { --primary: oklch(0.6 0.2 30); /* coral */ --primary-foreground: white; --radius: 0.75rem; } :root.dark { --primary: oklch(0.7 0.17 30); } ``` > **Selector specificity** The library declares its tokens on `:root` and `:root.dark` inside `@layer base`. Unlayered CSS beats layered CSS, so overrides written outside any layer (as above) always win, whatever the import order. ## Built-in themes The package ships six ready-made themes under `htmx-ui/themes/`: **aurora**, **ember**, **lagoon**, **dusk**, **jade** and **onyx**. They are opt-in and take two steps: import the files you want, then set `data-theme` on `` for the whole site or on any element for one part of a page. Projects that don't import a theme ship none of its CSS, and importing without the attribute styles nothing — so only what you use ships, and only where you ask for it. Each one in detail — preview, exact palette, a button that applies it to this site — is on the [Themes](https://susonwaiba.github.io/htmx-ui/docs/themes) page. ```css src/styles.css @import "tailwindcss"; @import "htmx-ui/styles.css"; /* One, or several — an attribute picks which applies where. */ @import "htmx-ui/themes/aurora.css"; @import "htmx-ui/themes/onyx.css"; ``` ```html
    …
    ``` ### Switching themes The options are buttons carrying `data-theme-palette` — this site's header is one, built on a dropdown so it works from the keyboard. `initTheme()` (already running if you followed the install guide) wires the clicks: the chosen palette lands on `` as `data-theme`, the option marks itself checked, and the choice is remembered in `localStorage` under `palette`. `getPalette()` and `setPalette(name)` are exported for switching from your own code. The `theme-swatch` dot paints itself with the palette in effect — give a swatch its own `data-theme` to show one specific palette instead. ```html ``` Like the light/dark switch, apply the stored choice before first paint with a tiny inline script in the layout's ``, reading the same keys as `htmx-ui/theme`: ```html ``` Both opt-in steps still hold: a picker can only switch to what the project imported, and it writes the same `data-theme` attribute the markup uses. ## Custom themes A theme is just a selector that redefines tokens — what the built-in files are. Scope one to an attribute and you can apply it to a whole page or a single section. These previews use the same markup, with a different `data-theme` on each wrapper: ```html

    default

    Pro

    Same markup, different tokens.

    rose

    Pro

    Same markup, different tokens.

    emerald

    Pro

    Same markup, different tokens.

    ``` ```css src/styles.css [data-theme="rose"] { --primary: var(--color-rose-600); --ring: var(--color-rose-500); --radius: 1rem; } .dark [data-theme="rose"] { --primary: var(--color-rose-500); } ``` ```html
    …
    ``` ## Scrollbars The library styles every scrollbar to match: thin, with a transparent track and a thumb tinted from `--foreground`, so it blends into any theme and follows dark mode. Browsers without `scrollbar-color` (Safari) get the same look through `::-webkit-scrollbar`. ```css src/styles.css :root { --scrollbar-thumb: color-mix(in oklab, var(--primary) 30%, transparent); } /* Native scrollbars for one element */ .native-scrollbars { scrollbar-color: auto; scrollbar-width: auto; } ``` ## Motion Components animate with a small set of duration and easing tokens, so the whole library moves the same way and one override retimes it. With `prefers-reduced-motion: reduce` every duration is `0s`: panels still open and close, without moving. | Token | Default | Used for | | --- | --- | --- | | `--motion-duration-fast` | 100ms | Small state changes, panels and tooltips leaving | | `--motion-duration-normal` | 150ms | Popovers, menus, lists, tooltips, hover cards | | `--motion-duration-moderate` | 200ms | Dialogs opening | | `--motion-duration-slow` | 300ms | Sheets | | `--motion-duration-slower` | 400ms | Drawers | | `--motion-ease-out` | quick start, soft landing | Things appearing, responding and leaving | | `--motion-ease-in-out` | symmetric | Things moving across the screen | | `--motion-ease-drawer` | long glide | Panels sliding from an edge | | `--motion-ease-spring` | about 8% overshoot | Playful moves; give it 200ms or more | ```css src/styles.css /* A calmer library: everything a little slower */ :root { --motion-duration-normal: 200ms; --motion-duration-moderate: 260ms; } ``` Use them in your own markup with Tailwind's variable shorthand: ```html
    …
    ``` ## Adding your own tokens Follow the same two steps to add a colour that Tailwind utilities can use: ```css src/styles.css :root { --brand-gradient-from: var(--color-fuchsia-500); } :root.dark { --brand-gradient-from: var(--color-fuchsia-400); } @theme inline { --color-brand-from: var(--brand-gradient-from); } ``` Now `from-brand-from`, `text-brand-from` and the rest are available, and they follow dark mode. ## Customising components Component classes live in `@layer components`, which sits below utilities. You can restyle them two ways: - **One element:** add utilities. ` ``` | Token | Light | Dark | | --- | --- | --- | | `--primary` | violet-600 | violet-500 | | `--primary-foreground` | white | white | | `--ring` | violet-500 | violet-400 | | `--brand-gradient` | violet-600 → indigo-600 45% → cyan-600 | violet-300 → indigo-300 45% → cyan-300 | | `--background` | white (default) | #0a0a0f | | `--surface` | white (default) | #12121b | | `--border` | zinc-200 (default) | #262633 | | `--radius` | 0.75rem | 0.75rem | Ships as `@import "htmx-ui/themes/aurora.css";`, applied by `data-theme="aurora"`; tokens not listed keep their [library value](https://susonwaiba.github.io/htmx-ui/docs/theming#tokens). ## ember Amber → orange → rose on warm paper, ember-lit near-black when the lights go out. 0.625rem corners. ```html

    ember

    Pro

    Orange buttons on warm paper (#fffbf7), gradient headlines, 0.625rem corners; coals in the dark.

    ``` | Token | Light | Dark | | --- | --- | --- | | `--primary` | orange-600 | orange-400 | | `--primary-foreground` | white | #20120a | | `--ring` | orange-500 | orange-300 | | `--brand-gradient` | amber-600 → orange-600 45% → rose-600 | amber-300 → orange-300 45% → rose-300 | | `--background` | #fffbf7 | #150e0a | | `--surface` | white (default) | #1f1510 | | `--border` | zinc-200 (default) | #382a21 | | `--radius` | 0.625rem | 0.625rem | Ships as `@import "htmx-ui/themes/ember.css";`, applied by `data-theme="ember"`; tokens not listed keep their [library value](https://susonwaiba.github.io/htmx-ui/docs/theming#tokens). ## lagoon Teal → cyan → sky on ice-clear light, deep-sea dark for the other side. The roundest-at-1rem corner of the set. ```html

    lagoon

    Pro

    Teal buttons on ice-clear light (#f7fdfd), gradient headlines, 1rem corners; underwater dark.

    ``` | Token | Light | Dark | | --- | --- | --- | | `--primary` | teal-600 | teal-400 | | `--primary-foreground` | white | #04191c | | `--ring` | cyan-500 | cyan-400 | | `--brand-gradient` | teal-600 → cyan-600 45% → sky-600 | teal-300 → cyan-300 45% → sky-300 | | `--background` | #f7fdfd | #04141a | | `--surface` | white (default) | #0a222b | | `--border` | zinc-200 (default) | #1c3d48 | | `--radius` | 1rem | 1rem | Ships as `@import "htmx-ui/themes/lagoon.css";`, applied by `data-theme="lagoon"`; tokens not listed keep their [library value](https://susonwaiba.github.io/htmx-ui/docs/theming#tokens). ## dusk Pink → fuchsia → purple twilight, with the roundest corners in the set (1.25rem). ```html

    dusk

    Pro

    Fuchsia buttons on a lilac haze (#fdf9ff), gradient headlines, extra-round corners; nightfall dark.

    ``` | Token | Light | Dark | | --- | --- | --- | | `--primary` | fuchsia-600 | fuchsia-400 | | `--primary-foreground` | white | #22082a | | `--ring` | fuchsia-500 | fuchsia-300 | | `--brand-gradient` | pink-600 → fuchsia-600 45% → purple-600 | pink-300 → fuchsia-300 45% → purple-300 | | `--background` | #fdf9ff | #120a16 | | `--surface` | white (default) | #1c1122 | | `--border` | zinc-200 (default) | #332340 | | `--radius` | 1.25rem | 1.25rem | Ships as `@import "htmx-ui/themes/dusk.css";`, applied by `data-theme="dusk"`; tokens not listed keep their [library value](https://susonwaiba.github.io/htmx-ui/docs/theming#tokens). ## jade Emerald → teal → lime: fresh mint in light, deep moss after rain in dark. The tightest colour corners at 0.5rem. ```html

    jade

    Pro

    Emerald buttons on barely-there mint (#f7fdf9), gradient headlines, 0.5rem corners; moss-dark.

    ``` | Token | Light | Dark | | --- | --- | --- | | `--primary` | emerald-600 | emerald-400 | | `--primary-foreground` | white | #06251a | | `--ring` | emerald-500 | emerald-300 | | `--brand-gradient` | emerald-600 → teal-600 45% → lime-600 | emerald-300 → teal-300 45% → lime-400 | | `--background` | #f7fdf9 | #07140f | | `--surface` | white (default) | #0d211a | | `--border` | zinc-200 (default) | #1e3a2e | | `--radius` | 0.5rem | 0.5rem | Ships as `@import "htmx-ui/themes/jade.css";`, applied by `data-theme="jade"`; tokens not listed keep their [library value](https://susonwaiba.github.io/htmx-ui/docs/theming#tokens). ## onyx Graphite on paper, silver on true black — the monochrome counterpoint to the colourful five, with crisp 0.375rem corners. ```html

    onyx

    Pro

    Near-black buttons on graphite white (#fafafa), gradient headlines, crisp corners; true-black dark with a silver button.

    ``` | Token | Light | Dark | | --- | --- | --- | | `--primary` | zinc-950 | zinc-100 | | `--primary-foreground` | white | zinc-950 | | `--ring` | zinc-700 | zinc-400 | | `--brand-gradient` | zinc-600 → zinc-950 45% → zinc-700 | zinc-400 → zinc-100 45% → zinc-300 | | `--background` | #fafafa | #0a0a0a | | `--surface` | white (default) | #141414 | | `--border` | zinc-200 (default) | #2a2a2a | | `--radius` | 0.375rem | 0.375rem | Ships as `@import "htmx-ui/themes/onyx.css";`, applied by `data-theme="onyx"`; tokens not listed keep their [library value](https://susonwaiba.github.io/htmx-ui/docs/theming#tokens). All six redefine the same small set — `--radius`, `--primary`, `--primary-foreground`, `--ring` and `--brand-gradient` — plus a tinted light `--background` where it suits, and in dark mode a brighter `--primary`, a new `--brand-gradient` and the `--background` / `--surface` / `--border` trio. Every other token keeps the library's value, so components never shift. Each file is a few hundred bytes: import all the ones you might switch between and the picker will offer exactly those. --- title: "Dark mode" description: "Light and dark are two sets of token values, switched by a .dark class on . The theme switcher follows the OS until the user picks, and never flashes the wrong theme." url: "/docs/dark-mode" section: "Customization" --- # Dark mode Light and dark are two sets of token values, switched by a .dark class on . The theme switcher follows the OS until the user picks, and never flashes the wrong theme. ## How it works 1. **A class, not a media query.** Tailwind v4's `dark:` variant normally follows `prefers-color-scheme`, which a button can't change. The library redefines it to match a `.dark` class instead. 2. **Tokens swap.** `:root.dark` redefines every colour variable, so components don't need `dark:` classes at all. 3. **Native widgets follow.** `color-scheme` switches too, so scrollbars and form controls match. ```css htmx-ui/styles.css (excerpt) @custom-variant dark (&:where(.dark, .dark *)); @layer base { :root { color-scheme: light; --background: var(--color-white); /* … */ } :root.dark { color-scheme: dark; --background: var(--color-zinc-950); /* … */ } } ``` ## Theme switcher Any element with `data-theme-toggle` becomes a toggle. Try this one; it's the same toggle as the one in the header. ```html ``` When clicked, `initTheme()` from `htmx-ui/theme`: - flips the `.dark` class on ``, - saves the choice to `localStorage` under the key `theme`, - sets `aria-pressed="true"` on every toggle while dark is active, and - writes the current theme (`light` or `dark`) into any `[data-theme-label]` inside a toggle. ```ts src/app.ts import { initTheme } from "htmx-ui/theme"; document.addEventListener("DOMContentLoaded", () => initTheme()); ``` ### Following the OS Until the user clicks a toggle, the theme follows `prefers-color-scheme` and updates live when the OS changes. After a click, the stored choice wins. To go back to following the OS, clear it: ```ts localStorage.removeItem("theme"); ``` ## Avoiding a flash of the wrong theme Module scripts run after the first paint, so on a dark-mode visit the page would briefly render light. Put this tiny inline script in ``, before your stylesheet and app script, so the class is set before anything is drawn: ```html ``` > **Keep it in sync** This script repeats the logic in `initTheme()`. If you change the storage key or the class name, change both. ## Writing dark styles Prefer tokens: `bg-surface` and `text-muted-foreground` already adapt. For one-off tweaks, use the `dark:` variant as usual: ```html
    Amber in light, a soft glow in dark.
    ``` ## Forcing a theme To keep part of the page dark (a code sample, a hero) whatever the global theme, add the `dark` class to that element. Everything inside picks up the dark variant. Note that `:root.dark` tokens only apply at the root, so set the tokens you need on the element too: ```css .force-dark { --background: var(--color-zinc-950); --foreground: var(--color-zinc-50); --border: var(--color-zinc-800); color-scheme: dark; } ``` ```html
    …
    ``` ## Building your own switcher `getTheme()` returns the active theme. A light/dark/system menu, for example, only needs to set or remove the stored value and toggle the class: ```ts import { getTheme } from "htmx-ui/theme"; function setTheme(choice: "light" | "dark" | "system") { if (choice === "system") localStorage.removeItem("theme"); else localStorage.setItem("theme", choice); const dark = choice === "system" ? matchMedia("(prefers-color-scheme: dark)").matches : choice === "dark"; document.documentElement.classList.toggle("dark", dark); } console.log(getTheme()); // "light" | "dark" ``` --- title: "Templates & data" description: "Pages are Nunjucks templates rendered at build time. They can read JSON files, inline SVGs and list files, so most content needs no API server." url: "/docs/templates" section: "Guides" --- # Templates & data Pages are Nunjucks templates rendered at build time. They can read JSON files, inline SVGs and list files, so most content needs no API server. > **Optional** The components are plain HTML and CSS, and work with any server or template language. This page covers the Nunjucks templates of [htmx-ui-engine](https://susonwaiba.github.io/htmx-ui/docs/engine), which this site is built with too. > **Macros are new** The package's Nunjucks macros (`htmx-ui/components/*.html`) need the engine's template helpers (`svg()`, `json()`, `| dedent`, `| highlight`). Their parameters may still change in a minor release before 1.0; each change is listed in the changelog. ## Pages and layouts Every `.html` file in `pages/` is a route (`docs/theming.html` is `/docs/theming`). The engine renders it with [Nunjucks](https://mozilla.github.io/nunjucks/templating.html) before the bundler (Bun's or Vite) sees it, so pages can extend layouts, include partials and call macros. Template names resolve from the project root, then from the htmx-ui package (its `components/` and `icons/`): ```jinja pages/pricing.html {% extends "layouts/site.html" %} {% from "components/alert/alert.html" import alert %} {% block title %}Pricing · HTMX UI{% endblock %} {% block content %} {% call alert("Launch offer", variant="success") %}20% off the first year.{% endcall %} {% endblock %} ``` ## JSON data `json(path)` reads a JSON file at build time. Keep repeated content (navigation, pricing tiers, team members, release notes) in one file and render it anywhere, with no API and no client-side fetching. ```jinja page.html {% set releases = json("data/changelog.json").releases %}
      {% for r in releases %}
    • v{{ r.version }}, {{ r.date or "Unreleased" }}
    • {% endfor %}
    ``` ```json data/changelog.json { "releases": [ { "version": "0.2.0", "summary": "Named template roots…" }, { "version": "0.1.0", "date": "2026-10-02", "summary": "First release…" } ] } ``` That template, live, reading this site's real file: - [v0.3.0](https://susonwaiba.github.io/htmx-ui/docs/changelog/0.3.0), 2026-10-08 - [v0.2.0](https://susonwaiba.github.io/htmx-ui/docs/changelog/0.2.0), 2026-10-06 - [v0.1.0](https://susonwaiba.github.io/htmx-ui/docs/changelog/0.1.0), 2026-10-02 This site's data files: | File (under site/) | Description | | --- | --- | | `data/site.json` | Site name, description and URL (used for sitemaps). | | `data/docs-nav.json` | Docs navigation: sidebar, prev/next, components index, page order for agents. | | `data/changelog.json` | Releases, newest first. | | `data/versions.json` | Docs versions; the latest is rendered live, older ones are archived. | ### JSON in the browser too The same files can be imported by client code. Bun and Vite bundle them as plain objects: ```ts import nav from "./data/docs-nav.json"; console.log(nav.sections.length); ``` ## SVG and icons `svg(path, attrs)` inlines an SVG file and sets attributes on its root, and the [icon macro](https://susonwaiba.github.io/htmx-ui/docs/components/icon) builds on it. Inlined SVG inherits colour and costs no extra requests. ```jinja {{ svg("icons/sun.svg", { class: "size-5 text-primary", "aria-hidden": "true" }) }} ``` ## Listing files `glob(pattern)` returns matching paths across the template roots, sorted. The icon gallery is generated this way, from the package's `icons/`: ```jinja {% for path in glob("icons/*.svg") %}…{% endfor %} ``` ## Assets `asset(path)` turns a root-relative path into one relative to the page being rendered, so a layout's ` ``` `assetVer(path)` is the same URL with `?ver=` appended: the project's own `version` from `package.json`, plus a random suffix per dev session (`?ver=1.4.0-k3f9` in dev). Use it for assets whose name doesn't change with their content, so a browser or proxy never serves a stale copy. ```jinja ``` A project with no `version` in `package.json` gets `assetVer()` == `asset()` in a build, and a random one in dev. It makes no difference for most files: both Bun and Vite name built assets after their content, and Bun's dev server does too, so their URLs always change with the file. It matters where a URL stays the same — Vite's dev server serves `/app.css` un-hashed, and `public/` files keep their names in `dist/`. ## Custom globals and filters The helpers above are the ones every site needs; whatever is specific to yours goes in `globals` and `filters` in `htmx-ui.config.ts`. Both are plain JavaScript, registered before every render, so a helper of your own is available wherever a built-in one is: layouts, partials, and macros imported without `with context`. ```ts htmx-ui.config.ts import { defineConfig, markup } from "htmx-ui-engine"; export default defineConfig({ globals: { // Any value you can compute at render time… siteName: "Acme", // …or a function, called with whatever the template passes. docsUrl: (path: string) => `https://docs.acme.com${path}`, badge: (label: string) => markup(`${label}`), }, filters: { slug: (text: unknown) => String(text).toLowerCase().replace(/\s+/g, "-"), }, }); ``` ```jinja {{ siteName }} API {{ badge("new") }} {{ "Acme Support" | slug }} ``` What you return is escaped like any other value, so a global that emits HTML wraps it in `markup()` — the same way `svg()` and `| highlight` return markup. An unknown name fails the build rather than rendering blank, which catches a typo in a helper's name too. Rendering a page yourself takes the same options: `site.render()` and the exported `render()` both accept `globals` and `filters`, alongside the `context` values a request supplies. ## Reference | Function / filter | Description | | --- | --- | | `json(path)` | Parsed JSON file. Paths are relative to a template root (the project, then htmx-ui); first match wins. | | `svg(path, attrs)` | Inline SVG with attributes set on the root . | | `glob(pattern)` | Sorted list of root-relative paths matching the pattern. | | `asset(path)` | Root-relative path rewritten relative to the current page (for

    With hx-preserve-scroll

    1. Item 1page load
    2. Item 2page load
    3. Item 3page load
    4. Item 4page load
    5. Item 5page load
    6. Item 6page load
    7. Item 7page load
    8. Item 8page load
    9. Item 9page load
    10. Item 10page load
    11. Item 11page load
    12. Item 12page load
    13. Item 13page load
    14. Item 14page load
    15. Item 15page load
    16. Item 16page load
    17. Item 17page load
    18. Item 18page load
    19. Item 19page load
    20. Item 20page load
    21. Item 21page load
    22. Item 22page load
    23. Item 23page load
    24. Item 24page load
    25. Item 25page load
    26. Item 26page load
    27. Item 27page load
    28. Item 28page load
    29. Item 29page load
    30. Item 30page load

    Without

    1. Item 1page load
    2. Item 2page load
    3. Item 3page load
    4. Item 4page load
    5. Item 5page load
    6. Item 6page load
    7. Item 7page load
    8. Item 8page load
    9. Item 9page load
    10. Item 10page load
    11. Item 11page load
    12. Item 12page load
    13. Item 13page load
    14. Item 14page load
    15. Item 15page load
    16. Item 16page load
    17. Item 17page load
    18. Item 18page load
    19. Item 19page load
    20. Item 20page load
    21. Item 21page load
    22. Item 22page load
    23. Item 23page load
    24. Item 24page load
    25. Item 25page load
    26. Item 26page load
    27. Item 27page load
    28. Item 28page load
    29. Item 29page load
    30. Item 30page load

    Two rows sharing hx-preserve-scroll="demo-row"

    1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 1.10 1.11 1.12 1.13 1.14 1.15 1.16 1.17 1.18 1.19 1.20
    2.1 2.2 2.3 2.4 2.5 2.6 2.7 2.8 2.9 2.10 2.11 2.12 2.13 2.14 2.15 2.16 2.17 2.18 2.19 2.20
    ``` Scroll the lists and rows, then re-render them: every element is replaced (the times change), but the marked ones keep their position. For a navigation sidebar, use [sidebar-active-scroll](#sidebar) instead. ## Setup Register the extension once, after htmx has loaded: ```ts app.ts import "htmx.org"; import { registerPreserveScroll } from "htmx-ui"; registerPreserveScroll(); // uses window.htmx; or registerPreserveScroll(htmx) ``` Like every htmx 4 extension it applies to the whole page once registered; there is no `hx-ext`. If you restrict extensions with `htmx.config.extensions`, add `preserve-scroll` to the list. Registering twice is harmless. ## Markup Put `hx-preserve-scroll` on the element that scrolls (the one with `overflow: auto`), and give it a key: ```html
    …
    …
    ``` - The key is what connects the old element with the new one. Use as many containers on a page as you like, each with its own key. An element with no key and no id is ignored. - **Repeated keys** are matched in document order: the first old element with the first new one, and so on. A list of horizontally scrolling rows can share one key, as the two rows in the demo above do. - The swap can replace the container itself (`outerHTML`), its parent, or the whole body in a boosted navigation. It works with OOB swaps and partials too. - `hx-preserve-scroll="false"` opts an element out, for a fragment that should start at the top even though it has the same id. ## How it works - Before each swap (`htmx:before:swap`) it records the scroll position of every marked element in the document. - New content is scrolled to the recorded position at `htmx:before:settle`, before the browser paints it, so it never flashes at the top. A last pass at `htmx:finally:swap` catches content from every task. - Elements the swap didn't replace are left alone, so a container the reader scrolls while the request is in flight keeps the reader's position. Morph swaps keep the element and therefore its scroll already. - Nothing is stored: positions are kept only for the duration of a swap. > **Content that grows after the swap** Positions are applied once, when the content is inserted. If images or lazy content above the position load later and change its height, give them a fixed size (`width` and `height` on images) so the position still lands on the same content. ## Sidebars: sidebar-active-scroll Keeping the position is not enough for a navigation sidebar. Open a link in a new tab, or reload, and there is no position to keep: the sidebar starts at the top, and the current page may be far below. A link in the page itself can lead to a page whose sidebar entry is out of view. `sidebar-active-scroll` handles both: - **Fresh page load** (new tab, reload, a sidebar new to the page): the active item is centred in the sidebar. - **After a swap**: the old position comes back, as with `hx-preserve-scroll`. If the new active item is then out of view, the sidebar scrolls just enough to show it, with a neighbouring item's height to spare. Clicking a link that is already visible doesn't move anything. - **A hidden sidebar** (an off-canvas panel with `display: none`) is centred the first time it gets a size. - Only the sidebar scrolls; the page never jumps. ```ts app.ts import { registerSidebarActiveScroll } from "htmx-ui"; registerSidebarActiveScroll(); ``` ```html ``` - Put `hx-sidebar-active-scroll` on the element that scrolls. Its value is the key, with the same rules as `hx-preserve-scroll` (empty: the id; `"false"`: off). - The active item is the first `[aria-current="page"]` or `[data-active]` inside, as the [Sidebar](https://susonwaiba.github.io/htmx-ui/docs/components/sidebar) component marks it. `hx-sidebar-active-item=""` picks something else. - Use one extension per container. This site's docs sidebar uses `sidebar-active-scroll`. ## API Exported from `htmx-ui`: - `registerPreserveScroll(htmx?)`: registers the extension as `preserve-scroll`. - `snapshotScroll(root?)` and `restoreScroll(snapshot, root?)`: the same capture and restore, for DOM you replace yourself. - `registerSidebarActiveScroll(htmx?)`: registers `sidebar-active-scroll` and centres the active item of sidebars already on the page. - `revealActive(container, mode?)`: scrolls a container so its active item shows, `"nearest"` (the default: only if it is out of view) or `"center"`. `revealAllActive(root?, mode?)` does the same for every `[hx-sidebar-active-scroll]`; call it after changing `aria-current` yourself. ```ts import { restoreScroll, snapshotScroll } from "htmx-ui"; const snapshot = snapshotScroll(); panel.innerHTML = html; restoreScroll(snapshot); ``` --- title: "AI agents" description: "Every docs page is published as Markdown, with an llms.txt index and JSON sitemaps, so coding agents can read the docs as specs." url: "/docs/ai-agents" section: "Guides" --- # AI agents Every docs page is published as Markdown, with an llms.txt index and JSON sitemaps, so coding agents can read the docs as specs. ## Markdown pages Add `.md` to any docs URL to get the page as Markdown: [/docs/components/button.md](https://susonwaiba.github.io/htmx-ui/docs/components/button.md), [/docs.md](https://susonwaiba.github.io/htmx-ui/docs.md). Each file starts with frontmatter (title, description, URL, section). Live examples become their source code, and reference tables become Markdown tables. The **Copy Markdown** button at the top of every page copies it for pasting into a chat. ````md /docs/components/badge.md --- title: "Badge" description: "A small label for status, counts or categories." url: "/docs/components/badge" section: "Components" --- # Badge A small label for status, counts or categories. ```html New ``` ```` ## Indexes Each opens in a new tab. | URL | Contents | | --- | --- | | [/llms.txt](https://susonwaiba.github.io/htmx-ui/llms.txt) | llms.txt index (llmstxt.org): every docs page as a Markdown link with its description, grouped by section. Start here. | | [/llms-full.txt](https://susonwaiba.github.io/htmx-ui/llms-full.txt) | Every docs page's Markdown in one file, for loading the whole library into context. | | [/sitemap.json](https://susonwaiba.github.io/htmx-ui/sitemap.json) | Every page with URL, Markdown URL, title, description, section, heading outline and section text, plus the list of docs versions. The index for site search. | | [/docs/sitemap.json](https://susonwaiba.github.io/htmx-ui/docs/sitemap.json) | The same for the latest docs only. Each older version has its own, e.g. /docs/v0.1/sitemap.json. | | [/docs/versions.json](https://susonwaiba.github.io/htmx-ui/docs/versions.json) | Every docs version with its URL prefix, its sitemap and the pages it contains. | | [/sitemap.xml](https://susonwaiba.github.io/htmx-ui/sitemap.xml) | Standard XML sitemap for search engines. | | [/robots.txt](https://susonwaiba.github.io/htmx-ui/robots.txt) | Allows crawling and points at the sitemap. | ```json /sitemap.json (excerpt) { "name": "HTMX UI", "url": "https://example.com", "version": "0.1", "versions": [ { "id": "0.1", "label": "v0.1", "path": "/docs", "latest": true, "sitemap": "/docs/sitemap.json" } ], "pages": [ { "url": "/docs/components/tabs", "markdown": "/docs/components/tabs.md", "title": "Tabs", "section": "Components", "headings": [{ "level": 2, "id": "markup", "text": "Markup" }], "sections": [{ "id": "markup", "title": "Markup", "text": "Each [data-tab] button shows the [data-tab-panel]…" }] } ] } ``` Every `sections[].id` is a heading anchor, so `url + "#" + id` opens that exact section. This site's own search (`Ctrl` `K`) works this way, and an MCP server can list pages from these files and link straight to the right place. ## Prompts These work with any agent that can fetch URLs (Claude, ChatGPT, Cursor, Copilot and others). Copy, then replace the task. ### Use the docs for one task ```text Prompt Read https://susonwaiba.github.io/htmx-ui/llms.txt. It indexes the htmx-ui docs; every link is a Markdown page. Fetch the pages relevant to the task below and follow their markup and class names exactly. Use htmx-ui's design tokens (bg-primary, text-muted-foreground, border-border) instead of raw colours. Task: build a settings page with tabs for Profile and Billing, and a danger-zone card with a delete button. ``` ### Point at specific pages ```text Prompt Build a settings page using htmx-ui. Follow these specs exactly: https://susonwaiba.github.io/htmx-ui/docs/components/card.md https://susonwaiba.github.io/htmx-ui/docs/components/tabs.md ``` ## Make it a skill Save the lookup steps as a skill so your agent uses the docs automatically, every time you work on UI, without pasting URLs again. Ask your agent to create it: ```text Prompt Create a skill named "htmx-ui" for this project (for Claude Code: .claude/skills/htmx-ui/SKILL.md). It should trigger whenever I build or change UI with htmx-ui components, classes, theming or dark mode. The skill must tell you to: 1. Fetch https://susonwaiba.github.io/htmx-ui/llms.txt to find the relevant docs pages. 2. Fetch those pages as Markdown (https://susonwaiba.github.io/htmx-ui/docs/.md) and follow their markup exactly. 3. Check the htmx-ui version in package.json; if it's older than the latest docs, read https://susonwaiba.github.io/htmx-ui/docs/versions.json and use that version's pages instead. 4. Prefer design tokens over raw Tailwind colours, and call initComponents() for new htmx content. Keep the skill short and link to the docs rather than copying them. ``` Or create the file yourself: ```md .claude/skills/htmx-ui/SKILL.md --- name: htmx-ui description: Build UI with the htmx-ui component library (Tailwind CSS v4 + htmx). Use when adding or changing buttons, cards, alerts, tabs, dropdowns, tables, prose, theming or dark mode in this project. --- # htmx-ui The docs are written for agents. Don't guess class names; look them up. 1. Fetch the index: https://susonwaiba.github.io/htmx-ui/llms.txt 2. Fetch the pages you need as Markdown, e.g. https://susonwaiba.github.io/htmx-ui/docs/components/tabs.md 3. Match the docs to the installed version: read "htmx-ui" in package.json. If it's older than the latest docs, use the matching version from https://susonwaiba.github.io/htmx-ui/docs/versions.json (pages live under its "path"). 4. Search for a topic in https://susonwaiba.github.io/htmx-ui/sitemap.json: `pages[].sections[]` hold the text of every section. Rules: - Copy markup and class names from the docs exactly. - Use design tokens (bg-primary, text-muted-foreground, border-border), not raw palette colours. - Wrap long-form text in .prose; give components inside it not-prose. - After htmx inserts new content, components initialise via initComponents() on htmx:after:process. ``` > **Other agents** The same text works as a Cursor rule (`.cursor/rules/htmx-ui.mdc`), a GitHub Copilot instruction file (`.github/copilot-instructions.md`) or a section in `AGENTS.md`. ## How it's generated Nothing is written by hand. The [docs plugin](https://susonwaiba.github.io/htmx-ui/docs/plugins/docs) (`htmx-ui-plugin-docs`, in the site's `site/htmx-ui.config.ts`) renders every page at build time, converts the article body to Markdown, and writes the indexes. The dev server generates the same files on request. Add it to your own site to publish the same files for your docs. - Page order follows the docs navigation in `site/data/docs-nav.json`. - Absolute URLs (and the URLs in the prompts above) use the `SITE_URL` environment variable, falling back to `url` in `site/data/site.json`. - Every docs heading gets an id and a `#` link, so sections can be linked directly. - Add `data-md-skip` to an element to leave it out of the Markdown and the search text (purely visual blocks). ```bash Terminal SITE_URL=https://htmx-ui.example bun run build ``` > **Write for both readers** Agents read exactly what people read, so a clear page works for both. Show the full markup in every example, say what each class does in the reference table, and describe behaviour in words, not just in a live demo. --- title: "Plugins" description: "Optional features on top of htmx-ui-engine: install a plugin, list it in your config, and its templates, routes, build output and commands join your site." url: "/docs/plugins" section: "Plugins" --- # Plugins Optional features on top of htmx-ui-engine: install a plugin, list it in your config, and its templates, routes, build output and commands join your site. Most sites are a handful of pages, and the engine keeps them small. Features only some sites need, such as a docs section, versioned docs or site search, are **plugins**: separate packages you install and list in `htmx-ui.config.ts`. A site that doesn't use one doesn't install it or load it, and nothing in its build changes. A plugin can add everything a config can: templates and macros, template globals and filters, a transform over every page, request handlers, files written by the build, and CLI commands. They work the same in `htmx-ui dev`, `htmx-ui build`, on Bun and on Node, and behind every [server adapter](https://susonwaiba.github.io/htmx-ui/docs/servers). ## Official plugins | Plugin | Package | Adds | | --- | --- | --- | | [Docs](https://susonwaiba.github.io/htmx-ui/docs/plugins/docs) | `htmx-ui-plugin-docs` | Heading anchors, Markdown for every docs page, llms.txt, JSON and XML sitemaps, navigation and page macros. | | [Versions](https://susonwaiba.github.io/htmx-ui/docs/plugins/versions) | `htmx-ui-plugin-versions` | Older releases' docs kept online as frozen builds, a version switcher, an old-version banner, and commands to name and archive versions. | | [Search](https://susonwaiba.github.io/htmx-ui/docs/plugins/search) | `htmx-ui-plugin-search` | A Ctrl/⌘K command palette that fuzzy-searches pages and sections. | They work alone and better together: search reads the sitemap the docs plugin publishes, and with the versions plugin, readers of old docs search that version and the sitemaps describe every version. This website uses all three. ## Using a plugin Install it, then list it under `plugins`: ```bash bun bun add htmx-ui-plugin-docs ``` ```bash npm npm install htmx-ui-plugin-docs ``` ```bash pnpm pnpm add htmx-ui-plugin-docs ``` ```bash yarn yarn add htmx-ui-plugin-docs ``` ```ts htmx-ui.config.ts import { defineConfig } from "htmx-ui-engine"; import docs from "htmx-ui-plugin-docs"; export default defineConfig({ url: "https://example.com", plugins: [docs({ name: "Acme", description: "Acme's docs." })], }); ``` Each plugin is a function: call it, with options if it takes any, and the result goes in the list. Restart `htmx-ui dev` after changing the list. A plugin that has a browser side or styles (the search plugin, for one) says so in its docs; those are two lines you add yourself, so you decide where they go: ```ts app.ts import { initSearch } from "htmx-ui-plugin-search/client"; document.addEventListener("DOMContentLoaded", () => { initSearch(); }); ``` ```css styles.css @import "tailwindcss"; @import "htmx-ui/styles.css"; @import "htmx-ui-plugin-docs/styles.css"; ``` A plugin's stylesheet also tells Tailwind to scan the plugin's templates, so the utilities they use are generated on Node too, where Vite doesn't look inside `node_modules`. ### Only in some environments `plugins` skips `false`, `null` and `undefined`, and flattens nested lists, so a plugin can be switched on by a condition: ```ts htmx-ui.config.ts const preview = process.env.PREVIEW === "1"; export default defineConfig({ plugins: [docs(), preview && search()], }); ``` ## Who wins Your own config always has the last word. Between plugins, the order of the list decides. | Hook | Description | | --- | --- | | `routes` | Merged. Your route on the same pattern replaces a plugin's; a later plugin's replaces an earlier one's. | | `globals, filters` | Merged the same way: yours win, then later plugins. | | `fetch` | Yours runs first; then each plugin's in order, until one answers. | | `transform` | Each plugin's in order, then yours, so yours sees their output. | | `build.done` | Each plugin's in order, then yours. | | `Templates` | Your project's roots first, then each plugin's, then htmx-ui's. A file of yours with the same name as a plugin's template replaces it. | | `Commands` | A plugin can't replace dev, build or preview, and two plugins can't add the same command: the config refuses to load. | ## Overriding a plugin's templates Plugins keep their templates in a directory named after themselves, such as `search/macros.html`, and their template root is searched after yours. To change one, copy it into your project under the same name and edit it: your copy wins, everywhere it is used. ```text my-site/ search/macros.html your own search() replaces the plugin's pages/ layouts/ ... ``` > **Keep the data attributes** A plugin's browser code finds its markup by `data-*` attributes (`data-search-dialog`, `data-version-switcher`, ...). Change the classes and the layout as you like; keep the attributes its docs list. ## Plugin commands A plugin can add commands to the CLI. `htmx-ui --help` lists the ones your plugins add: ```txt htmx-ui --help ... Plugin commands: versions:name name the docs version in development (versions) versions:archive [--skip-build] build, freeze the latest docs version, start a new next (versions) ``` They run on the same runtime as the rest of the CLI, so add them to `package.json` scripts like any other: ```json package.json { "scripts": { "docs:archive": "htmx-ui versions:archive" } } ``` ## Writing your own A plugin is an object with a name and the hooks it needs, so a project can keep one in its own repository before it is worth publishing. See [Writing a plugin](https://susonwaiba.github.io/htmx-ui/docs/plugins/writing). --- title: "Docs plugin" description: "htmx-ui-plugin-docs: a docs section people and AI agents can both read. Heading anchors, Markdown for every page, llms.txt, sitemaps, navigation and page macros." url: "/docs/plugins/docs" section: "Plugins" --- # Docs plugin htmx-ui-plugin-docs: a docs section people and AI agents can both read. Heading anchors, Markdown for every page, llms.txt, sitemaps, navigation and page macros. `htmx-ui-plugin-docs` turns the pages under `/docs` into documentation: every section linkable, every page also published as Markdown, and indexes for search engines, site search and AI agents. One navigation file drives the sidebar, previous/next links and the order of everything it publishes. This website's docs are built with it; see [AI agents](https://susonwaiba.github.io/htmx-ui/docs/ai-agents) for what the output looks like. ## Install ```bash bun bun add htmx-ui-plugin-docs ``` ```bash npm npm install htmx-ui-plugin-docs ``` ```bash pnpm pnpm add htmx-ui-plugin-docs ``` ```bash yarn yarn add htmx-ui-plugin-docs ``` ```ts htmx-ui.config.ts import { defineConfig } from "htmx-ui-engine"; import docs from "htmx-ui-plugin-docs"; export default defineConfig({ url: "https://example.com", // absolute URLs in the sitemaps; $SITE_URL overrides it plugins: [docs({ name: "Acme", description: "Acme's docs." })], }); ``` ```css styles.css @import "tailwindcss"; @import "htmx-ui/styles.css"; @import "htmx-ui-plugin-docs/styles.css"; ``` There is no browser module to add: the "Copy Markdown" button is a [clipboard](https://susonwaiba.github.io/htmx-ui/docs/components/clipboard) button that fetches the page's `.md`, so htmx-ui's `initComponents()` runs it. (`htmx-ui-plugin-docs/client` still exports `initMarkdownCopy()`, for pages built with the plugin's older markup.) ## The docs layout The plugin reads pages the way they are rendered, so it needs three things from your docs layout: an `
    ` whose first `h1` is the page title, the page body inside `
    `, and a ``. An `.eyebrow` in the article names the page's section. ```jinja layouts/docs.html {% extends "layouts/base.html" %} {% from "docs/macros.html" import markdown_actions %} {% set nav = docsNav(url) %} {% block content %} {% endblock %} ``` ## Navigation One file lists the docs in order, in sections. It is found through the template roots, like `json()`: ```json data/docs-nav.json { "sections": [ { "title": "Getting started", "items": [ { "title": "Introduction", "href": "/docs" }, { "title": "Installation", "href": "/docs/installation", "description": "Set it up." } ] } ] } ``` `docsNav(url)` returns where a page sits in it: | Field | Description | | --- | --- | | `sections` | The file's sections, for the sidebar. | | `pages` | Every entry, in order. | | `current` | The page's own entry, or null when it is not listed. | | `active` | href of the closest entry: the page's own, or the longest one its URL starts with. /docs/servers/express marks /docs/servers active when it has no entry of its own, so a section can have pages of its own. | | `section` | Title of the active entry's section ("Docs" when none), for the eyebrow. | | `prev, next` | The entries before and after the page's own, or null. | Entries can carry anything else you need (this site marks components with `"component": true` to list them on the components page). Pages missing from the file still get Markdown, and sort with their closest listed parent. ## Heading anchors Every `h2` and `h3` in `[data-docs-content]` gets an `id` from its text and a `#` link, styled by htmx-ui's `.heading-anchor`. An explicit `id` is kept, so give one to a heading whose wording may change while links to it must not. Headings inside links, `.not-prose` component markup, `.demo` previews and `[data-md-skip]` are not sections and are left alone. The HTML, the Markdown and the sitemap all see the same ids. ## What it publishes | URL | Description | | --- | --- | | `.md` | Each docs page as Markdown with frontmatter: /docs.md, /docs/setup.md. Demos become their source, tables Markdown tables, alerts blockquotes. | | `/llms.txt` | llmstxt.org index: every docs page as a Markdown link with its description, grouped by section. | | `/llms-full.txt` | Every docs page's Markdown in one file. | | `/sitemap.json` | Every page with title, description, section, outline and section text. The index site search reads. | | `/docs/sitemap.json` | The same for the docs only. | | `/sitemap.xml` | For search engines. The 404 page is left out of both sitemaps. | | `/robots.txt` | Points crawlers at the sitemap. A robots.txt in public/ is used instead. | `htmx-ui dev` and the [server adapters](https://susonwaiba.github.io/htmx-ui/docs/servers) generate them on request; `htmx-ui build` writes them to `dist/`. Mark purely visual blocks `data-md-skip` to leave them out of the Markdown and the search text. ## Macros ```jinja {% from "docs/macros.html" import demo, classes, markdown_actions %} {% call demo() %}{% endcall %} {{ classes([["btn", "The base class."], ["btn-primary", "The main action."]]) }} {{ markdown_actions(url) }} ``` | Macro | Description | | --- | --- | | `demo(class, source, isolate)` | A live preview of the call body with its source underneath. In Markdown it becomes just the source. | | `classes(rows, heading)` | A two-column reference table of [name, description] rows. | | `markdown_actions(page_url)` | A "Copy Markdown" clipboard button and a "View .md" link for the page. | ## Options | Option | Description | | --- | --- | | `prefix` | Route prefix of the docs. Default "/docs". | | `nav` | The navigation file. Default "data/docs-nav.json"; false for none. | | `name, description` | The site, for llms.txt and sitemap.json. | | `anchors` | Heading ids and # links. Default true. | | `robots` | Publish /robots.txt. Default true. | > **Set the site URL for production builds** Sitemaps and llms.txt carry absolute URLs from `url` in the config, or `$SITE_URL`. The build warns when neither is set. ## For other plugins Its `api` renders and converts pages for plugins that build on it: ```ts const docs = config.plugins.find((p) => p.name === "docs")?.api as DocsApi | undefined; for (const page of docs?.pages() ?? []) console.log(page.url, page.title, page.sections.length); ``` `pages()` renders every page (with `title`, `description`, `section`, `headings` and `sections`), `markdown(page)` converts one, and `isDocs(url)` tells docs pages from the rest. The converters and generators are exported too: `pageMarkdown`, `pageMeta`, `addHeadingAnchors`, `sitemapJson`, `llmsTxt`. --- title: "Versions plugin" description: "htmx-ui-plugin-versions: keep every release's docs online as a frozen build, with a version switcher, an old-version banner and commands to name and archive versions." url: "/docs/plugins/versions" section: "Plugins" --- # Versions plugin htmx-ui-plugin-versions: keep every release's docs online as a frozen build, with a version switcher, an old-version banner and commands to name and archive versions. `htmx-ui-plugin-versions` keeps the docs of every release online. The version being written renders live at `/docs`, as usual. Each older one is a **frozen build** in `archive/v/`, served at `/docs/v/`: its pages, scripts and styles exactly as they were released, so later changes to components or layouts can never break them. This website's [versions](https://susonwaiba.github.io/htmx-ui/docs/versions) work this way. ## Install ```bash bun bun add htmx-ui-plugin-versions ``` ```bash npm npm install htmx-ui-plugin-versions ``` ```bash pnpm pnpm add htmx-ui-plugin-versions ``` ```bash yarn yarn add htmx-ui-plugin-versions ``` ```ts htmx-ui.config.ts import { defineConfig } from "htmx-ui-engine"; import docs from "htmx-ui-plugin-docs"; import versions from "htmx-ui-plugin-versions"; export default defineConfig({ plugins: [docs(), versions()], }); ``` List the versions. The one being written has no number yet; it is `next` until it is released: ```json data/versions.json { "latest": "next", "versions": [{ "id": "next", "label": "next" }] } ``` ```css styles.css @import "tailwindcss"; @import "htmx-ui/styles.css"; @import "htmx-ui-plugin-versions/styles.css"; ``` ```ts app.ts import { initVersions } from "htmx-ui-plugin-versions/client"; document.addEventListener("DOMContentLoaded", () => initVersions()); ``` It works without the docs plugin, but with it each archived version also keeps its Markdown and its own sitemap, the sitemaps list every version, and [search](https://susonwaiba.github.io/htmx-ui/docs/plugins/search) follows the version being read. ## The switcher and the banner ```jinja layouts/docs.html {% from "versions/macros.html" import version_switcher, version_banner %}
    {{ version_banner() }}

    {{ title }}

    ``` | Template | Description | | --- | --- | | `version_switcher(class, links)` | A Dropdown listing every version. links are extra entries under a separator: [{ title, href }]. At runtime the client points each version at the same page when it exists there, and adds versions released after the page was built. | | `version_banner()` | Where an archived page's "you're viewing an old version" notice goes, linking to the same page in the latest version. Empty on the latest docs. | | `docsVersions()` | The versions file as templates see it: latest, current, label, manifest and versions (id, label, released, path, latest). | Archiving bakes the banner into every frozen page, so it shows without JavaScript. The client only refreshes it, so an old version learns about releases that came after it. ## Releasing a version The plugin adds two CLI commands. Give them scripts, so they run on the runtime you build with: ```json package.json { "scripts": { "docs:name": "htmx-ui versions:name", "docs:archive": "htmx-ui versions:archive" } } ``` ```bash bun bun run docs:name 1.2.0 bun run docs:archive ``` ```bash npm npm run docs:name 1.2.0 npm run docs:archive ``` ```bash pnpm pnpm run docs:name 1.2.0 pnpm run docs:archive ``` ```bash yarn yarn run docs:name 1.2.0 yarn run docs:archive ``` 1. **`versions:name `** numbers `next` and dates it today. Docs are versioned per minor before 1.0 (`0.2.0` becomes `0.2`) and per major after (`1.2.0` becomes `1`); pass `0.2` or `1` to choose yourself. It refuses when the latest version already has a number. 2. **`versions:archive`** builds the site, freezes the latest version's built docs into `archive/v/` and opens a new `next`. Links between docs pages are rewritten into the snapshot, assets move to `_assets/`, pages get `noindex` so old versions don't compete with the latest in search results, and every `` gets `hx-boost="false"` so nothing on a frozen page boosts the live site's code into it. `--skip-build` archives the `dist/` you already have. Commit `archive/` and `data/versions.json`. > **Never edit the archive by hand** Snapshots are built output. Fix an old version by rebuilding it from its release and archiving it again. Patch releases update the current docs in place rather than getting a version of their own. ## URLs | URL | Description | | --- | --- | | `/docs/...` | The latest version, rendered from pages/docs. | | `/docs/v/...` | An archived version, from archive/v/. Also /docs/v.md and each page's .md with the docs plugin. | | `/docs/versions.json` | The manifest: every version with its path, release date, sitemap and the pages it has. | The version in development never reaches a URL: it is always served at `/docs`, and only archived versions get a `/v` prefix. Links that must point across versions carry `data-version-link`, which archiving leaves alone, and `hx-boost="false"`: every version ships its own theme and scripts, so switching versions must load the target page fresh instead of swapping the body under this version's stylesheet. Archiving applies the same rule to the whole snapshot, by setting `hx-boost="false"` on its ``. A frozen page ships its own theme, stylesheet and scripts, so no link inside it — its logo, footer, sidebar or a link in the prose — may be boosted into the current site, and no current page may be swapped into it: every navigation out of a snapshot is a full page load. ## Options | Option | Description | | --- | --- | | `prefix` | Route prefix of the docs, the same as the docs plugin's. Default "/docs". | | `file` | The versions file, relative to the project root. Default "data/versions.json". | | `archive` | Where frozen versions are kept, relative to the project root. Default "archive". | The functions behind the commands are exported for release scripts of your own: `nameVersion`, `startNext`, `archiveDocs`, `versionId`, `readVersionsFile` and `writeVersionsFile`. This repository's `bun run version:set` uses them to name the docs version and set its package versions in one step. --- title: "Search plugin" description: "htmx-ui-plugin-search: a Ctrl/⌘K command palette that fuzzy-searches every page and section, with no server or search service." url: "/docs/plugins/search" section: "Plugins" --- # Search plugin htmx-ui-plugin-search: a Ctrl/⌘K command palette that fuzzy-searches every page and section, with no server or search service. `htmx-ui-plugin-search` adds a search button and a command palette. It opens with `Ctrl`/`⌘` `K` or `/`, matches pages and the sections inside them as you type, and opens the page or jumps straight to the section. There is no server and no search service: it reads one JSON index, once, the first time it opens (or as soon as the pointer reaches the button). Try it in this site's header. ## Install The index is the `sitemap.json` the [docs plugin](https://susonwaiba.github.io/htmx-ui/docs/plugins/docs) publishes, so install both: ```bash bun bun add htmx-ui-plugin-search htmx-ui-plugin-docs ``` ```bash npm npm install htmx-ui-plugin-search htmx-ui-plugin-docs ``` ```bash pnpm pnpm add htmx-ui-plugin-search htmx-ui-plugin-docs ``` ```bash yarn yarn add htmx-ui-plugin-search htmx-ui-plugin-docs ``` ```ts htmx-ui.config.ts import { defineConfig } from "htmx-ui-engine"; import docs from "htmx-ui-plugin-docs"; import search from "htmx-ui-plugin-search"; export default defineConfig({ plugins: [docs(), search()], }); ``` ```css styles.css @import "tailwindcss"; @import "htmx-ui/styles.css"; @import "htmx-ui-plugin-search/styles.css"; ``` ```ts app.ts import { initSearch } from "htmx-ui-plugin-search/client"; document.addEventListener("DOMContentLoaded", () => initSearch()); ``` Put the button and the palette in your header: ```jinja partials/header.html {% from "search/macros.html" import search %}
    ... {{ search() }}
    ``` ## search() | Argument | Description | | --- | --- | | `src` | The index to search. Default "/sitemap.json". | | `label` | The button's text. Default "Search docs…". | | `placeholder` | The input's placeholder. | It renders a wide button with the shortcut on larger screens, an icon button on small ones, and one ``. Every element the client needs carries a `data-search-*` attribute; [override the template](https://susonwaiba.github.io/htmx-ui/docs/plugins#overriding) to change the markup and keep those. ## How it matches Every page is one record and every section of a page (the text under an `h2` or `h3`) is another, so a hit can open `page#section`. Each word of the query must match somewhere in a record. A word scores by how it matches, best first: the exact word, the start of a word, anywhere in a word, its letters in order, then one typo; and by where, the title above a heading above the text. Results are grouped by page, best page first. | Key | Description | | --- | --- | | `↑ ↓` | Move through the results. | | `Enter` | Open the result. Ctrl/⌘ + Enter opens it in a new tab. | | `Esc` | Close. So does clicking outside the palette. | ## With versioned docs With the [versions plugin](https://susonwaiba.github.io/htmx-ui/docs/plugins/versions), a reader of an archived version searches that version's own sitemap, so results stay in the docs they are reading. The palette's footer says which version it is searching. ## Your own index Any JSON in the sitemap format works, so a blog or a site without the docs plugin can generate one from its `build.done` hook and pass it as `src`: ```json /search.json { "pages": [ { "url": "/blog/hello", "title": "Hello", "description": "The first post.", "section": "Blog", "sections": [{ "id": "why", "title": "Why", "text": "Plain text under the heading…" }] } ] } ``` The matcher is exported for a search UI of your own: `buildIndex(sitemap)` turns a sitemap into records, and `searchIndex(records, query, limit)` returns ranked results with a highlighted snippet. --- title: "Writing a plugin" description: "Build an htmx-ui plugin: the hooks, templates, page edits with editHtml(), routes and build output, CLI commands, browser code, testing and publishing." url: "/docs/plugins/writing" section: "Plugins" --- # Writing a plugin Build an htmx-ui plugin: the hooks, templates, page edits with editHtml(), routes and build output, CLI commands, browser code, testing and publishing. A plugin is an object with a `name` and the hooks it needs. Most hooks are config options you already know (`globals`, `transform`, `routes`, `fetch`, `build.done`), so anything a config can do, a plugin can package up. The engine merges every plugin into the config once, when it loads, and every runtime and adapter reads that merged config: a plugin never needs to know whether it is running under `htmx-ui dev`, a build, Vite or a server. Start in your own project. A plugin can live in a file next to `htmx-ui.config.ts` and be imported like any module; publish it once another site wants it. ## A complete example This plugin publishes an Atom feed of the pages under `/blog`. It uses most hooks: a template with a `` macro, a global, a transform, a route for development, a file for the build, and a command. ```ts plugins/feed/index.ts import { definePlugin, editHtml, pagesOf, renderPage, type ResolvedConfig } from "htmx-ui-engine"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; export interface FeedOptions { /** Pages under this prefix are posts. */ prefix?: string; title: string; } export function feed({ prefix = "/blog", title }: FeedOptions) { let config: ResolvedConfig; /** Every post, newest first, from its and

    . */ function posts() { return pagesOf(config) .filter((p) => p.url.startsWith(prefix + "/")) .map((p) => { let date = ""; let heading = ""; let inH1 = false; editHtml(renderPage(config, p.file), { element(el) { if (el.matches('meta[name="date"]')) date = el.getAttribute("content") ?? ""; if (el.tagName === "h1") { inH1 = true; el.onEndTag(() => void (inH1 = false)); } }, text(chunk) { if (inH1) heading += chunk; }, }); return { url: config.origin + p.url, title: heading.trim(), date }; }) .sort((a, b) => b.date.localeCompare(a.date)); } function xml() { const items = posts().map((p) => `${p.title}${p.date}`); return `\n${title}${items.join("")}\n`; } return definePlugin({ name: "feed", // templates/feed/link.html, reached as "feed/link.html" roots: [fileURLToPath(new URL("./templates", import.meta.url))], configResolved(c) { config = c; }, globals: { feedUrl: "/feed.xml" }, // Posts get an id on their
    , so other pages can link to them transform(html, page) { if (!page.url.startsWith(prefix + "/")) return html; return editHtml(html, { element(el) { if (el.tagName === "article" && !el.hasAttribute("id")) el.setAttribute("id", "post"); }, }); }, // dev and server adapters: generated on request routes: { "/feed.xml": () => new Response(xml(), { headers: { "Content-Type": "application/atom+xml; charset=utf-8" } }), }, // htmx-ui build: written next to the pages build: { async done({ outDir }) { await writeFile(join(outDir, "feed.xml"), xml()); }, }, commands: { "feed:list": { description: "list the posts in the feed", run() { for (const p of posts()) console.log(p.date, p.title); }, }, }, }); } export default feed; ``` ```jinja plugins/feed/templates/feed/link.html {% macro feed_link() %}{% endmacro %} ``` Use it like any other plugin: ```ts htmx-ui.config.ts import { defineConfig } from "htmx-ui-engine"; import feed from "./plugins/feed"; export default defineConfig({ url: "https://example.com", plugins: [feed({ title: "My blog" })], }); ``` ```jinja layouts/post.html {% from "feed/link.html" import feed_link %} {{ feed_link() }} ``` ## Hooks Every hook is optional except `name`. | Hook | Description | | --- | --- | | `name` | Unique. Other plugins find this one by it, and a config listing two plugins with one name refuses to load. | | `roots` | Template roots, searched after the project's own and before htmx-ui's. Absolute, or relative to the project root. | | `globals, filters` | Nunjucks globals and filters. The project's own of the same name win. | | `transform(html, page)` | Post-process every rendered page, in dev, builds and servers. page is { file, url }. Runs before the project's own transform. | | `routes` | Request handlers in Bun.serve route syntax ("/feed.xml", "/api/:id", "/files/*"), served by htmx-ui dev and by server adapters. Not part of a static build. | | `fetch(req)` | Fallback for requests nothing else answered; return null to pass. Runs after the project's own fetch. | | `build.done(ctx)` | After a production build, with { config, outDir, pages }: write files the static site needs. Runs before the project's own. | | `commands` | CLI commands by name: { "feed:list": { description, usage, run(ctx) } }. | | `configResolved(config)` | Called once the config is resolved, before anything renders, serves or builds. Keep the config for the other hooks. | | `api` | Anything the plugin offers other plugins. | > **A route serves, a build writes** `routes` answer only where a server runs. For a file the static site needs too, like the feed above, generate it in a route for development and write the same thing in `build.done`. Both are cheap to keep in sync when one function makes the content. ## Runs on Bun and Node Plugin code runs inside the config, and on Node the config is loaded by Vite. So a plugin uses `node:` APIs (`node:fs`, `node:path`) rather than `Bun.file` or `Bun.write`, and never `HTMLRewriter`, which Node does not have. - Read the config in `configResolved`, not at module scope: the same module may be loaded for several configs. - Globals and filters are synchronous, like Nunjucks itself: read files with `readFileSync` there. - Routes, fetch, build.done and commands may be `async`. - Find your own files from `import.meta.url` (`new URL("./templates", import.meta.url)`), never from the working directory. ## Editing pages: editHtml() `editHtml(html, handlers)` from `htmx-ui-engine` is a small streaming HTML editor for transforms. It behaves like Bun's `HTMLRewriter` where they overlap (text and attribute values arrive as written, entities not decoded; `setAttribute` escapes only `"`), and it leaves every tag you don't touch byte for byte, so an edit never reformats a page. ```ts import { editHtml } from "htmx-ui-engine"; const html = editHtml(page, { element(el) { if (el.matches("a[href^='http']") && !el.closest("nav")) el.setAttribute("rel", "noopener"); if (el.matches("h2") && el.closest("[data-docs-content]")) el.append(' ↑'); }, text(chunk, parents) { // each run of text, with the elements open around it }, }); ``` | Element | Description | | --- | --- | | `tagName, parents` | The lowercased tag name; the open ancestors, outermost first. | | `getAttribute, hasAttribute, setAttribute, removeAttribute` | Attributes, as written. | | `matches(selector), closest(selector)` | Tag, #id, .class and [attr] tests (=, ~=, ^=, $=, *=), comma-separated. No combinators: closest() walks the ancestors. | | `before, prepend, append, after (html)` | Insert markup around or inside the element. | | `replace(html), remove()` | Replace or drop the whole element. | | `onEndTag(fn)` | Run when the element closes: its text has been seen by then. | ## Templates Keep a plugin's templates in a directory named after it (`templates/feed/link.html`, reached as `feed/link.html`), so they can't collide with a project's own or another plugin's. Prefer macros to partials: a macro takes arguments, and a project calls it where it wants. Plugin templates can use htmx-ui's components (`{% from "components/icon/icon.html" import icon %}`), since htmx-ui's `src/` is searched after them. A project overrides a template by having a file with the same name, so treat the names as your public API, and document the `data-*` attributes your browser code relies on. ## Commands `run` gets a context and returns an exit code (nothing means 0). Throw to fail with a message. | Context | Description | | --- | --- | | `config` | The resolved config, plugins included. | | `args` | Positional arguments after the command: htmx-ui versions:name 0.2.0 gives ["0.2.0"]. | | `options` | Options as parsed: --force gives { force: true }, --to=x gives { to: "x" }. | | `build()` | Build the site the way htmx-ui build does, on the runtime the CLI is running on. Resolves to whether it succeeded. | Name commands `:`. A plugin can't replace `dev`, `build` or `preview`, and two plugins can't share a command. ## Working with other plugins A plugin finds another by name in `config.plugins` and uses its `api`. Look it up when you need it, not in `configResolved`: the other plugin may come later in the list. Treat it as optional and do something sensible without it, as the official plugins do with each other: ```ts const docs = config.plugins.find((p) => p.name === "docs")?.api as { pages(): { url: string; title: string }[] } | undefined; const titles = docs ? docs.pages().map((p) => p.title) : []; ``` ## Browser code and styles The config side of a plugin never reaches the browser. Ship browser code as a separate entry (`your-plugin/client`) that the project imports in its `app.ts`, and styles as a stylesheet it imports after htmx-ui's: ```ts src/client.ts import { queryAll } from "htmx-ui"; export function initFeedLinks(root: ParentNode = document) { queryAll(root, "[data-feed-link]:not([data-init])").forEach((el) => { el.dataset.init = ""; // ... }); } ``` ```css src/styles.css /* Tailwind must see the utilities in the templates: Vite skips node_modules */ @source "./"; @layer components { .feed-link { @apply inline-flex items-center gap-1 text-muted-foreground; } } ``` Follow htmx-ui's component contract: take a `root`, find elements with `queryAll` (it includes `root`), and mark each with `data-init`, so the function can also run on content htmx swaps in. Build styles from htmx-ui's tokens (`bg-primary`, `text-muted-foreground`) so they follow the theme and dark mode. ## Testing Resolve a config with your plugin over a temporary project, then render pages with `renderPage` and send requests through `createSite`, which answers like every adapter does: ```ts index.test.ts import { expect, test } from "bun:test"; import { createSite, renderPage, resolveConfig } from "htmx-ui-engine"; import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import feed from "./index"; test("publishes the feed", async () => { const root = await mkdtemp(join(tmpdir(), "feed-")); await mkdir(join(root, "pages/blog"), { recursive: true }); await writeFile(join(root, "pages/blog/hello.html"), '

    Hello

    '); const config = resolveConfig({ url: "https://x.test", plugins: [feed({ title: "Blog" })] }, root); expect(renderPage(config, join(root, "pages/blog/hello.html"))).toContain('
    '); const site = await createSite({ config }); const res = await site.handle(new Request("http://localhost/feed.xml")); expect(await res.text()).toContain("Hello"); }); ``` `config.user.build.done({ config, outDir, pages })` runs the build hooks without a build, and `config.plugins.find(...).commands` holds the commands to call directly. ## Publishing - Name it `htmx-ui-plugin-` and add the keyword `htmx-ui-plugin`, so it can be found. - Default-export a function that returns the plugin, taking an options object. - Make `htmx-ui-engine` (and `htmx-ui`, when you use its components) peer dependencies. - Ship compiled JavaScript for Node and for browser bundlers, which don't use the `bun` condition, and the TypeScript source for Bun. Templates and CSS ship as they are. ```json package.json { "name": "htmx-ui-plugin-feed", "type": "module", "keywords": ["htmx-ui", "htmx-ui-plugin"], "sideEffects": ["**/*.css"], "exports": { ".": { "bun": "./src/index.ts", "types": "./lib/index.d.ts", "default": "./lib/index.js" }, "./client": { "bun": "./src/client.ts", "types": "./lib/client.d.ts", "default": "./lib/client.js" }, "./styles.css": "./src/styles.css" }, "files": ["lib", "src"], "peerDependencies": { "htmx-ui": "^0.2.0", "htmx-ui-engine": "^0.2.0" } } ``` A compiled `lib/index.js` sits beside `src/`, so point at templates through the package root and both find them: `new URL("../src/templates", import.meta.url)`. The official plugins in [the repository](https://github.com/susonwaiba/htmx-ui/tree/main/packages) are complete examples: `plugin-search` is the smallest, templates and a browser module; `plugin-versions` has commands. --- title: "Server frameworks" description: "Serve a built htmx-ui site from your own backend: Elysia, Fastify, Koa, Express, Hono, Bun.serve, Deno, or a backend in another language." url: "/docs/servers" section: "Servers" --- # Server frameworks Serve a built htmx-ui site from your own backend: Elysia, Fastify, Koa, Express, Hono, Bun.serve, Deno, or a backend in another language. A built htmx-ui site is just files: pages at their routes, hashed scripts and styles, and whatever you put in `public/`. Any server can host them. `htmx-ui-engine` adds adapters that put those files and your own API in **one process**: your routes answer what they claim, and htmx-ui answers the rest with the built site. The same templates also render the HTML fragments your API returns to htmx. ## Pick your server | Server | Use | Order | Runs on | | --- | --- | --- | --- | | [Elysia](https://susonwaiba.github.io/htmx-ui/docs/servers/elysia) (recommended) | `htmx-ui-engine/elysia` | Any | Bun, Node | | [Fastify](https://susonwaiba.github.io/htmx-ui/docs/servers/fastify) | `htmx-ui-engine/fastify` | Any | Node, Bun | | [Koa](https://susonwaiba.github.io/htmx-ui/docs/servers/koa) | `htmx-ui-engine/koa` | Any | Node, Bun | | [Express](https://susonwaiba.github.io/htmx-ui/docs/servers/express) | `htmx-ui-engine/express` | Last | Node, Bun | | [Hono](https://susonwaiba.github.io/htmx-ui/docs/servers/hono) | `htmx-ui-engine/hono` | Last | Node, Bun, Deno | | [Bun.serve](https://susonwaiba.github.io/htmx-ui/docs/servers/bun) | `createSite()` | — | Bun | | [Deno, node:http, h3, NestJS](https://susonwaiba.github.io/htmx-ui/docs/servers/other) | `createSite()` or an adapter | — | Deno, Node, Bun | | [Django, Rails, Laravel, Go…](https://susonwaiba.github.io/htmx-ui/docs/servers/any-backend) | The built files | — | Any | **Order** is where the adapter has to go relative to your own routes. "Any" means it only answers what no route of yours matched, wherever you register it. "Last" means it is ordinary middleware that answers every request it sees, so it has to come after your routes. The adapters use files on disk, so edge runtimes without a filesystem (Cloudflare Workers and the like) are not supported. ## Quick start Add the engine and your framework as **dependencies**. Your server imports the engine at runtime, so a production install that skips dev dependencies would leave it out. ```bash bun bun add htmx-ui-engine elysia ``` ```bash npm npm install htmx-ui-engine elysia ``` ```bash pnpm pnpm add htmx-ui-engine elysia ``` ```bash yarn yarn add htmx-ui-engine elysia ``` Mount the adapter next to your own routes: ```ts Elysia import { Elysia } from "elysia"; import { htmxUi } from "htmx-ui-engine/elysia"; new Elysia() .get("/api/ping", () => ({ ok: true })) .use(htmxUi()) // everything else is the built site .listen(3000); ``` ```ts Fastify import Fastify from "fastify"; import { htmxUi } from "htmx-ui-engine/fastify"; const app = Fastify(); app.get("/api/ping", async () => ({ ok: true })); app.register(htmxUi()); // everything else is the built site await app.listen({ port: 3000 }); ``` ```ts Koa import Koa from "koa"; import { htmxUi } from "htmx-ui-engine/koa"; const app = new Koa(); app.use(htmxUi()); // answers only what nothing else did app.use(async (ctx, next) => { if (ctx.path === "/api/ping") ctx.body = { ok: true }; else await next(); }); app.listen(3000); ``` ```ts Express import express from "express"; import { htmxUi } from "htmx-ui-engine/express"; const app = express(); app.get("/api/ping", (req, res) => res.json({ ok: true })); app.use(htmxUi()); // last: everything else is the built site app.listen(3000); ``` ```ts Hono import { Hono } from "hono"; import { htmxUi } from "htmx-ui-engine/hono"; const app = new Hono(); app.get("/api/ping", (c) => c.json({ ok: true })); app.use(htmxUi()); // last: everything else is the built site export default app; ``` ```ts Bun.serve import { createSite } from "htmx-ui-engine"; const site = await createSite(); Bun.serve({ port: 3000, routes: { "/api/ping": () => Response.json({ ok: true }) }, fetch: (request) => site.handle(request), // everything else is the built site }); ``` Then build the site and start your server: ```json package.json { "scripts": { "dev": "htmx-ui dev", "build": "htmx-ui build", "start": "NODE_ENV=production bun server.ts" } } ``` On Node, `start` is `NODE_ENV=production node server.ts` (Node 22.18 and later run TypeScript directly), or your compiled `server.js`. While you work on pages, `htmx-ui dev` is still the quicker loop, with hot reload and your mock endpoints. Your server is for the API, and for production. ## How a request is answered Your framework sees each request first, so its routes and middleware keep the paths they claim. Whatever is left goes to the adapter, which tries in this order: 1. The config's `routes`, the same ones `htmx-ui dev` serves (see [Mock htmx endpoints](https://susonwaiba.github.io/htmx-ui/docs/engine#mock-endpoints)). They answer in production too, so [keep mocks out of production](https://susonwaiba.github.io/htmx-ui/docs/servers/deploying#mock-routes). 2. `dist/`: a page at its route, a hashed asset, or a `public/` file. 3. **Development only:** the page template for that route, rendered on the spot. See [Development and production](#dev-prod). 4. The config's `fetch` fallback. 5. A [404 page](#not-found). `debug: ["requests"]` in `htmx-ui.config.ts` logs which step answered each request and how long it took. See [Debugging](https://susonwaiba.github.io/htmx-ui/docs/engine#debug). ### The 404 page Every path ends with an answer, so a request never falls through to your framework's own 404. What it gets, in order: 1. `dist/404.html`, built from your `pages/404.html`, with your layout and styles. 2. In development, before anything is built: `pages/404.html`, rendered on the spot. 3. htmx-ui's default: a small, self-contained page that follows the visitor's light or dark preference. To use your own, add `pages/404.html`. It is an ordinary page that extends your layout, and it is served with a `404` status. A build with no `pages/404.html` writes the default to `dist/404.html`, so a static host serves the same page. `htmx-ui dev` and `htmx-ui preview` answer the same way. ```jinja pages/404.html {% extends "layouts/base.html" %} {% block content %}

    Page not found

    Try the home page.

    {% endblock %} ``` > **htmx swaps error responses** htmx 4 swaps a `404` response like any other. If an `hx-get` asks for a path nothing answers, the whole 404 page lands in its target. Fix the path, or tell that element not to swap a 404: `hx-status:404="swap:none"`. ### Where to mount it **Elysia** registers a catch-all route, and Elysia's router always prefers a specific route. **Fastify** becomes the not-found handler. **Koa** runs the rest of the stack first and only answers when nothing else did. For these three, registration order doesn't matter. **Express** and **Hono** run middleware in order, and the adapter answers everything it sees. Register it **after** your own routes and middleware, or they will never be reached. ## Development and production `NODE_ENV` decides one thing: whether pages may be rendered from their templates. - **Not `production`**: a route `dist/` has no file for is rendered from its template, so a server you start before building still shows every page. Templates are re-read on every render, so edits show on the next request; pages you add are found without a restart; and `public/` is served as it is, before a build copies it. The scripts and stylesheets a rendered page links (`{{ asset('app.ts') }}`) are bundled from source when the browser asks for them, Tailwind included, and kept in memory until a file changes; then open pages reload. Nothing is written to `dist/`. `dist/` still answers first, so an old build hides your template edits. Rebuild, or delete `dist/` while you work. - **`production`**: pages come only from `dist/`, so run `htmx-ui build` first. Fragments and `site.render()` still render at request time, and a page rendered that way links the bundles the build made for the same scripts and stylesheets, from the manifest the build writes (`dist/.htmx-ui/manifest.json`). See [Deploying](https://susonwaiba.github.io/htmx-ui/docs/servers/deploying) for what that needs on the server. ## Loading htmx-ui.config.ts The adapters read `htmx-ui.config.ts` from the working directory at startup. Bun and Deno import TypeScript directly, and so does Node from 22.18. On an older Node, name the file `htmx-ui.config.js` (or `.mjs`), or pass the config yourself: `htmxUi({ config: { routes } })`. ## Next - [Pages & fragments](https://susonwaiba.github.io/htmx-ui/docs/servers/rendering): render the per-user part of a page with `site.fragment()` and `site.render()`, handle forms and errors. - [Deploying](https://susonwaiba.github.io/htmx-ui/docs/servers/deploying): what to ship, cache headers, Docker and reverse proxies. - [`createSite()` API](https://susonwaiba.github.io/htmx-ui/docs/servers/api): every option and method. --- title: "Pages & fragments" description: "Render the per-user part of a page as an htmx fragment from your server, render whole pages per request, and handle forms, htmx headers and errors." url: "/docs/servers/rendering" section: "Servers" --- # Pages & fragments Render the per-user part of a page as an htmx fragment from your server, render whole pages per request, and handle forms, htmx headers and errors. A page in `pages/` is rendered once, by `htmx-ui build`, and served as a file. Every visitor gets the same bytes, which any cache can keep. Anything that depends on _who is asking_ can't be in that file. It belongs in a **fragment**: a small piece of HTML your server renders per request, and htmx swaps into the page. Fragments are templates, like pages, so they use the same macros, components and data. Create one site and use it both to render fragments and to serve the build: ```ts Elysia import { Elysia } from "elysia"; import { createSite } from "htmx-ui-engine"; import { htmxUi } from "htmx-ui-engine/elysia"; const site = await createSite(); const html = (body: string, status = 200) => new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8" } }); const app = new Elysia() // your routes, returning html(site.fragment(...)) .use(htmxUi({ site })) .listen(3000); ``` ```ts Fastify import Fastify from "fastify"; import { createSite } from "htmx-ui-engine"; import { htmxUi } from "htmx-ui-engine/fastify"; const site = await createSite(); const app = Fastify(); // your routes, replying with reply.type("text/html").send(site.fragment(...)) app.register(htmxUi({ site })); await app.listen({ port: 3000 }); ``` ```ts Koa import Koa from "koa"; import Router from "@koa/router"; import { createSite } from "htmx-ui-engine"; import { htmxUi } from "htmx-ui-engine/koa"; const site = await createSite(); const app = new Koa(); const router = new Router(); // your routes, setting ctx.type = "html" and ctx.body = site.fragment(...) app.use(htmxUi({ site })); app.use(router.routes()); app.listen(3000); ``` ```ts Express import express from "express"; import { createSite } from "htmx-ui-engine"; import { htmxUi } from "htmx-ui-engine/express"; const site = await createSite(); const app = express(); // your routes, sending res.type("html").send(site.fragment(...)) app.use(htmxUi({ site })); // last app.listen(3000); ``` ```ts Hono import { Hono } from "hono"; import { createSite } from "htmx-ui-engine"; import { htmxUi } from "htmx-ui-engine/hono"; const site = await createSite(); const app = new Hono(); // your routes, returning c.html(site.fragment(...)) app.use(htmxUi({ site })); // last export default app; ``` `htmxUi({ site })` serves that same instance, so its templates are read and compiled once. Without `site`, the adapter creates a second site. ## When the HTML depends on who is asking At build time there is no request, so there is no user. Here is the piece that usually needs one: a sign-in link for a visitor, a name and a sign-out button for a member. ```jinja partials/user-menu.html {% if user %} {{ user.name }}
    {% else %} Sign in {% endif %} ``` The built page keeps a placeholder where the menu goes and asks for it as soon as it loads. Without `hx-trigger="load"`, a `
    ` only sends its request when clicked: ```html layouts/base.html ``` Your server renders the partial for the current request: ```ts Elysia app.get("/api/user-menu", ({ request }) => html(site.fragment("partials/user-menu.html", { user: currentUser(request) })), ); ``` ```ts Fastify app.get("/api/user-menu", async (request, reply) => reply.type("text/html").send(site.fragment("partials/user-menu.html", { user: currentUser(request) })), ); ``` ```ts Koa router.get("/api/user-menu", (ctx) => { ctx.type = "html"; ctx.body = site.fragment("partials/user-menu.html", { user: currentUser(ctx) }); }); ``` ```ts Express app.get("/api/user-menu", (req, res) => { res.type("html").send(site.fragment("partials/user-menu.html", { user: currentUser(req) })); }); ``` ```ts Hono app.get("/api/user-menu", (c) => c.html(site.fragment("partials/user-menu.html", { user: currentUser(c) }))); ``` The page stays a static file anyone can cache, the fragment is small, and the markup lives in one template. Use this pattern for a header, a nav, a sidebar, or any list of the visitor's own rows. > **Only the render needs the user** Your framework's middleware sees the request first, so `currentUser()` can use a cookie, a session or a token, whatever you already have. Templates get the user as a context value. They never read the request themselves. ## Fragments `site.fragment(path, context)` renders any template (a partial, a macro wrapper, a whole page you want in a modal) with the config's globals plus `context`. It adds no document shell and doesn't run the config's `transform()`. It returns a string, so send it with an HTML content type. Elysia, for one, sends a bare string as `text/plain`. ```jinja partials/invoice-row.html {{ invoice.number }} {{ invoice.total }} ``` ```ts Elysia app.post("/api/invoices/:id/refund", ({ params }) => { const invoice = refund(params.id); return html(site.fragment("partials/invoice-row.html", { invoice })); }); ``` ```ts Fastify app.post<{ Params: { id: string } }>("/api/invoices/:id/refund", async (request, reply) => { const invoice = refund(request.params.id); return reply.type("text/html").send(site.fragment("partials/invoice-row.html", { invoice })); }); ``` ```ts Koa router.post("/api/invoices/:id/refund", (ctx) => { const invoice = refund(ctx.params.id); ctx.type = "html"; ctx.body = site.fragment("partials/invoice-row.html", { invoice }); }); ``` ```ts Express app.post("/api/invoices/:id/refund", (req, res) => { const invoice = refund(req.params.id); res.type("html").send(site.fragment("partials/invoice-row.html", { invoice })); }); ``` ```ts Hono app.post("/api/invoices/:id/refund", (c) => { const invoice = refund(c.req.param("id")); return c.html(site.fragment("partials/invoice-row.html", { invoice })); }); ``` ## Render a page per request `site.render(url, context)` renders the page at a route, with its layout and the config's `transform()`, just as the build does but with your context added. Use it when most of the page depends on the request. When only a corner does, a static page plus a [fragment](#per-user) is cheaper and caches better. ```ts server.ts (Elysia) app.get("/dashboard", ({ request }) => html(site.render("/dashboard", { user: currentUser(request) }))); ``` The page's scripts and stylesheets work as they do in a built page: in development they are bundled from source on request, and in production they are the build's bundles (see [Development and production](https://susonwaiba.github.io/htmx-ui/docs/servers#dev-prod)). Register the route yourself: a page in `dist/` is only served when no route of yours claims the path. `render()` throws for a route with no page. Pass values in the context, not in config globals, so one visitor's data never reaches another's render. ## One route, fragment or page htmx marks its requests with `HX-Request: true`. A route can use that to answer htmx with a fragment and a direct visit (a bookmark, a reload, a link opened in a new tab) with the whole page: ```ts server.ts (Elysia) app.get("/invoices", ({ request }) => { const invoices = listInvoices(new URL(request.url).searchParams); return request.headers.get("HX-Request") ? html(site.fragment("partials/invoice-table.html", { invoices })) : html(site.render("/invoices", { invoices })); }); ``` If you also cache that route, send `Vary: HX-Request`, so the fragment and the page are cached separately. Response headers steer htmx from the server: | Header | Effect | | --- | --- | | `HX-Redirect` | Full navigation to a URL, e.g. after sign-in. | | `HX-Refresh` | `true` reloads the page. | | `HX-Trigger` | Fires events on the client, e.g. to refresh a list elsewhere on the page. | | `HX-Retarget`, `HX-Reswap` | Swap somewhere else, or another way, than the element asked for. | | `HX-Push-Url`, `HX-Replace-Url` | Update the address bar. | ## Forms A form with `hx-post` sends its fields as `application/x-www-form-urlencoded`, the same as without htmx. Read them however your framework does: a body parser, `request.formData()`, `c.req.parseBody()`. Answer with the fragment to swap in. When validation fails, re-render the form with its errors and a `422`. htmx 4 swaps error responses too, so the errors appear where the form was: ```jinja partials/invite-form.html
    {% if errors.email %}
    {{ errors.email }}
    {% endif %}
    ``` ```ts server.ts (Elysia) app.post("/api/invites", async ({ request }) => { const values = Object.fromEntries(await request.formData()); const errors = validateInvite(values); if (errors) return html(site.fragment("partials/invite-form.html", { values, errors }), 422); await sendInvite(values.email); return html(site.fragment("partials/invite-sent.html", { email: values.email })); }); ``` The config's `routes` take a web `Request`, so they call `request.formData()` on every adapter. When a body parser (`express.urlencoded()`, Fastify's JSON parser, `@koa/bodyparser`) has already read the body, the adapter rebuilds it from what was parsed. ## Errors `render()` and `fragment()` throw when a template is missing or fails to render. The error reaches your framework's error handling like any other: Express's error middleware (the adapter calls `next(error)`), a `try`/`catch` middleware in Koa, Fastify's `setErrorHandler()`, Elysia's `onError` and Hono's `app.onError`. To answer htmx with something it can swap, render an error fragment there: ```ts server.ts (Elysia) app.onError(({ error, request }) => { console.error(error); if (request.headers.get("HX-Request")) { return html(site.fragment("partials/error.html", { message: "Something went wrong. Try again." }), 500); } }); ``` Set `debug: ["render"]` in `htmx-ui.config.ts` to log every render with its duration; see [Debugging](https://susonwaiba.github.io/htmx-ui/docs/engine#debug). ## Asset URLs in fragments A fragment rarely needs a URL. `svg()` and the `icon()` macro inline their markup, so there is no file to fetch. When one does link a project file, `asset()` returns a path from the project root (`/images/logo.png`), which your server only serves if that file is in `dist/`. Put it in `public/`, which the build copies as-is, or map URLs yourself with [`options.asset`](https://susonwaiba.github.io/htmx-ui/docs/servers/api#options). Built pages already carry hashed URLs, so this only affects `render()` and `fragment()`. Scripts and stylesheets in a page `render()` returns are the exception: their source links are swapped for bundles (from source in development, the build's in production). In production that needs a page the build has that links the same files, which every page using your layout does. A script only some renders link (say, behind `{% if user.admin %}`) needs a built page that links it too; otherwise the server says which one it could not find and leaves the source link. --- title: "Deploying" description: "Ship an htmx-ui site with your server: what to copy, NODE_ENV, keeping mock routes out of production, cache headers, Docker and reverse proxies." url: "/docs/servers/deploying" section: "Servers" --- # Deploying Ship an htmx-ui site with your server: what to copy, NODE_ENV, keeping mock routes out of production, cache headers, Docker and reverse proxies. The adapters serve files, so deploying is whatever your server already does: build during the release, then run the server with `NODE_ENV=production`. The one real decision is which routes your server answers. That decides what you have to ship next to `dist/`. ## Three shapes ### 1. Static only Every page is the same for everyone and htmx talks to an API on another host. Run `htmx-ui build` and put `dist/` on a static host, a CDN or an object store. You need no server and no adapter. The config's `routes` and `fetch` are not part of the build, so a static host never answers them. `dist/404.html` is your 404 page, or htmx-ui's default. ### 2. Static pages, dynamic fragments The usual shape. Pages come from `dist/`, and your server renders the per-visitor parts as [fragments](https://susonwaiba.github.io/htmx-ui/docs/servers/rendering#per-user). A fragment is a template rendered at request time, so the server needs the **Nunjucks templates** as well as `dist/`. `htmx-ui build` doesn't copy them: it writes built pages and hashed assets, nothing else. The simplest deployment ships the project as it is, source and `dist/` together, and needs no configuration: the defaults already point at `pages/` and the project root. To ship less, copy the template directories into the deployment and point the config at the copy: ```bash Terminal htmx-ui build mkdir -p dist/_templates cp -r layouts partials pages data dist/_templates/ ``` Copy every directory a template reaches at render time: `macros/` and `components/` too if your templates import from them, and whatever `json()` and `svg()` read. The build's `dist/.htmx-ui/manifest.json` ships with `dist/`: it is how a page `site.render()` returns links the built scripts and stylesheets instead of their sources. ```ts htmx-ui.config.ts import { defineConfig } from "htmx-ui-engine"; export default defineConfig({ // This deployment renders templates at runtime: check at startup that they arrived. render: true, // "." stays the project root; the copy is searched second. roots: [".", "dist/_templates"], pages: "dist/_templates/pages", }); ``` With `render: true`, a packaging mistake stops the server at startup instead of causing a 500 later. `createSite()` checks the template roots and the pages directory, and if they are missing the error says what to copy. Without it, a deployment that forgot its templates starts normally and fails on the first htmx request. ### 3. Everything from your server Your server renders each page per request with `site.render()`. This is shape 2 plus your own page routes. Ship the templates, set `render`, and register a route for each page you render, since a route you don't register is still served from `dist/`. Caching is then up to you: a page rendered per visitor must not be cached as if it were the same for everyone. ```ts server.ts (Elysia) app.get("/dashboard", ({ request }) => html(site.render("/dashboard", { user: currentUser(request) }))); ``` ## NODE_ENV=production Outside production, the adapter renders a page from its template whenever `dist/` has no file for that route (see [Development and production](https://susonwaiba.github.io/htmx-ui/docs/servers#dev-prod)). That is convenient on your machine and wrong in production, so set `NODE_ENV=production` wherever the server runs. With it set, pages come only from the build, and a missing page gets your 404 page. ## Keep mock routes out of production The config's `routes` are mock endpoints for `htmx-ui dev`, but the adapters serve them too, ahead of `dist/`. Where your server has the real endpoints, a mock on the same path would never be reached. On a path your server doesn't have, a mock would answer in production. Leave them out when it is not development: ```ts htmx-ui.config.ts import { defineConfig } from "htmx-ui-engine"; import { mocks } from "./mocks"; export default defineConfig({ routes: process.env.NODE_ENV === "production" ? {} : mocks, }); ``` ## Cache headers and compression The adapters set content types and nothing else. The usual split: hashed files in `dist/assets/` can be cached forever, because a new build gives them new names. HTML should be revalidated on every visit, so a deploy shows up at once. Add the header around the adapter: ```ts Elysia const cacheControl = (path: string) => path.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache"; new Elysia() .onAfterHandle(({ request, response }) => { if (response instanceof Response) response.headers.set("Cache-Control", cacheControl(new URL(request.url).pathname)); }) .use(htmxUi()) .listen(3000); ``` ```ts Fastify const cacheControl = (path: string) => path.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache"; app.addHook("onSend", async (request, reply) => { reply.header("Cache-Control", cacheControl(request.url.split("?")[0]!)); }); app.register(htmxUi()); // compression: @fastify/compress ``` ```ts Koa const cacheControl = (path: string) => path.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache"; app.use(async (ctx, next) => { await next(); ctx.set("Cache-Control", cacheControl(ctx.path)); }); app.use(htmxUi()); // compression: koa-compress ``` ```ts Express const cacheControl = (path: string) => path.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache"; app.use(compression()); // the compression package app.use((req, res, next) => { res.setHeader("Cache-Control", cacheControl(req.path)); next(); }); // ...your routes app.use(htmxUi()); ``` ```ts Hono import { compress } from "hono/compress"; const cacheControl = (path: string) => path.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache"; app.use(compress()); app.use(async (c, next) => { await next(); c.res.headers.set("Cache-Control", cacheControl(c.req.path)); }); // ...your routes app.use(htmxUi()); ``` These set the header on every response, including your API's. Narrow the check if your API sets its own. A CDN or a reverse proxy in front of the server can do both jobs instead (see [below](#reverse-proxy)). ## Docker Build in one stage and run in another, with production dependencies only. The engine is a regular dependency, so it is installed in the run stage: ```docker Dockerfile FROM oven/bun:1 AS build WORKDIR /app COPY package.json bun.lock ./ RUN bun install --frozen-lockfile COPY . . RUN bun run build FROM oven/bun:1 WORKDIR /app ENV NODE_ENV=production COPY package.json bun.lock ./ RUN bun install --frozen-lockfile --production COPY --from=build /app/dist ./dist COPY htmx-ui.config.ts server.ts ./ # Shapes 2 and 3: the templates the server renders from. COPY layouts ./layouts COPY partials ./partials COPY pages ./pages COPY data ./data EXPOSE 3000 CMD ["bun", "server.ts"] ``` On Node, start from `node:22` (22.18 or later runs `server.ts` directly), use `npm ci` and `npm ci --omit=dev`, and `CMD ["node", "server.ts"]`. The build stage needs the dev dependencies (Tailwind, and Vite on Node); the run stage doesn't. ## Behind a reverse proxy nginx or Caddy in front of the server can serve `dist/assets/` straight from disk, add cache headers and compression, and pass everything else to your process: ```nginx nginx.conf server { listen 80; root /srv/app/dist; location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; } location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } gzip on; gzip_types text/css text/javascript application/javascript application/json image/svg+xml; } ``` To skip the Node or Bun process entirely, with nginx serving the pages and your backend answering htmx, see [Django, Rails, Go and others](https://susonwaiba.github.io/htmx-ui/docs/servers/any-backend). ## Checklist - `htmx-ui-engine` and your framework are in `dependencies`, not `devDependencies`. - `htmx-ui build` runs during the release, before the server starts. - `NODE_ENV=production` is set where the server runs. - The templates are deployed when the server renders fragments or pages, and `render: true` is set. - Mock `routes` are off in production. - A `pages/404.html` if you want your own 404 page. - Cache headers: forever for `/assets/`, revalidate for HTML. --- title: "createSite() API" description: "Reference for createSite(), the Site it returns and the options every server adapter takes." url: "/docs/servers/api" section: "Servers" --- # createSite() API Reference for createSite(), the Site it returns and the options every server adapter takes. `createSite()` is what every adapter runs underneath, and what you use directly on a server without an adapter. It reads the config, finds the pages, compiles their templates, and returns a `Site` that renders templates and answers requests. ```ts server.ts import { createSite, type Site, type SiteOptions } from "htmx-ui-engine"; const site: Site = await createSite({ context: { company: "Acme" } }); site.fragment("partials/invoice-row.html", { invoice }); // an htmx fragment, as a string site.render("/dashboard", { user }); // a whole page, as a string await site.handle(request); // a Response for any request ``` Every adapter entry (`htmx-ui-engine/elysia`, `/fastify`, `/koa`, `/express`, `/hono`) also re-exports `createSite` and its types, so one import is enough. ## Site | Member | Description | | --- | --- | | `site.config` | The resolved config: absolute root, roots, pagesDir, outDir and publicDir, plus origin, render, and the config as you wrote it (user). | | `site.pages` | Every page as { file, path, url }, sorted by route. Found at startup; in development, found again when a route has no page or a page's file is gone, so pages you add or remove need no restart. | | `site.render(url, context?)` | Render the page at a route ("/", "/docs/setup", trailing slash or not) with its layout and the config's transform(). Throws when no page has that route. | | `site.fragment(path, context?)` | Render any template by its path from a root ("partials/row.html") as an HTML fragment: no document shell, no transform(). Throws when the template doesn't exist. | | `site.handle(request, context?)` | Answer a web Request: the config's routes, then dist/, then (outside production) the page's template, then the config's fetch, then a 404 page. Always resolves to a Response. context reaches the templates it renders. | Both renders see the config's `globals`, then `options.context`, then the `context` you pass, with later values winning. See [How a request is answered](https://susonwaiba.github.io/htmx-ui/docs/servers#request-order) for `handle()`'s order, and [The 404 page](https://susonwaiba.github.io/htmx-ui/docs/servers#not-found) for its last step. ## Options `createSite(options)` and every adapter's `htmxUi(options)` take the same options: | Option | Description | | --- | --- | | `root` | Project root, where htmx-ui.config.ts and pages/ live. Default: process.cwd(). | | `config` | A config object to use instead of reading htmx-ui.config.ts: what defineConfig() takes, or a resolved config. | | `context` | Values for every render, on top of the config's globals. | | `asset(file, page)` | URL for asset() in render() and fragment(). file is the absolute file, page the template rendering it. Default: a path from the project root (/images/logo.png). | | `cache` | Keep compiled templates between renders, and compile everything the pages reach at startup. Default: true when NODE_ENV is production, false otherwise, so template edits show on the next request. false re-reads every template on every render. | | `site` | A Site, or a promise of one, to use instead of creating one: htmxUi({ site }) serves the instance your routes render with. | > **asset() and request-time renders** Built pages already carry hashed asset URLs, so `handle()` never calls `asset()`. It matters only for `render()` and `fragment()`. By default it returns a path from the project root, which your server only serves if the file is in `dist/`; scripts and stylesheets in a rendered page are swapped for bundles instead. See [Asset URLs in fragments](https://susonwaiba.github.io/htmx-ui/docs/servers/rendering#assets). ## Config options for servers These settings in `htmx-ui.config.ts` matter when a server is involved (the rest are in [Configuration](https://susonwaiba.github.io/htmx-ui/docs/engine#config)): | Option | Description | | --- | --- | | `routes` | Request handlers keyed by path ("/api/users/:id"). Served by htmx-ui dev and by the adapters, never by a static host. | | `fetch(request)` | Fallback for requests nothing else matched. Return null to let the 404 page answer. | | `render` | true when this deployment renders templates at runtime: createSite() checks at startup that the roots and pages are there. See Deploying. | | `debug` | true, or a list of topics (requests, render, templates, build): log what answered each request and how long renders took. | ## Adapters Each adapter is one function, `htmxUi(options?)`, and imports nothing from its framework. | Entry | Description | | --- | --- | | `htmx-ui-engine/elysia` | An Elysia plugin: a catch-all route your own routes always beat. Returns the instance, so .use(htmxUi()).listen() chains. | | `htmx-ui-engine/fastify` | A Fastify plugin that sets the not-found handler. Register it once, on the root instance. | | `htmx-ui-engine/koa` | Koa middleware that answers after the rest of the stack, only when nothing set a body or a status. | | `htmx-ui-engine/express` | Connect-style middleware for Express 4 and 5, Connect and node:http. Mount it last. | | `htmx-ui-engine/hono` | Hono middleware. Register it last. | --- title: "Elysia" description: "Mount a built htmx-ui site as an Elysia plugin (the recommended adapter), and render pages and fragments from your handlers." url: "/docs/servers/elysia" section: "Servers" --- # Elysia Mount a built htmx-ui site as an Elysia plugin (the recommended adapter), and render pages and fragments from your handlers. `htmx-ui-engine/elysia` exports `htmxUi()`, a plugin that answers the paths your own routes don't. Start with this adapter: Elysia always prefers a specific route over the plugin's catch-all, so the order you register things in never matters. It runs on Bun and on Node. ```bash bun bun add htmx-ui-engine elysia ``` ```bash npm npm install htmx-ui-engine elysia ``` ```bash pnpm pnpm add htmx-ui-engine elysia ``` ```bash yarn yarn add htmx-ui-engine elysia ``` ## Mount it ```ts server.ts import { Elysia } from "elysia"; import { htmxUi } from "htmx-ui-engine/elysia"; new Elysia() .get("/api/invoices", () => db.invoices.list()) .post("/api/invoices", ({ body }) => db.invoices.create(body)) .use(htmxUi()) // pages, assets, public files, the config's routes, a 404 page .listen(3000); ``` The plugin returns the instance it was given, so `.use(htmxUi()).listen(3000)` chains as usual. It handles every method, so the config's `routes` answer `hx-post` as well as `hx-get`. Paths nothing claims get the [404 page](https://susonwaiba.github.io/htmx-ui/docs/servers#not-found): your `pages/404.html`, or htmx-ui's default. Because the catch-all answers them, Elysia's own `NOT_FOUND` error never fires. ## Fragments and pages Elysia sends a returned string as `text/plain`, so wrap rendered HTML in a `Response`. A small helper keeps routes short: ```ts server.ts import { Elysia } from "elysia"; import { createSite } from "htmx-ui-engine"; import { htmxUi } from "htmx-ui-engine/elysia"; const site = await createSite(); const html = (body: string, status = 200) => new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8" } }); new Elysia() .post("/api/invoices/:id/refund", ({ params }) => html(site.fragment("partials/invoice-row.html", { invoice: refund(params.id) })), ) .get("/dashboard", ({ request }) => html(site.render("/dashboard", { user: currentUser(request) }))) .use(htmxUi({ site })) // the same site: templates compiled once .listen(3000); ``` [Pages & fragments](https://susonwaiba.github.io/htmx-ui/docs/servers/rendering) covers when to use which, forms, htmx headers and error fragments. ## Options `htmxUi(options)` takes the options of [`createSite()`](https://susonwaiba.github.io/htmx-ui/docs/servers/api#options): `root`, `config`, `context`, `asset`, `cache` and `site`. For example, to serve a site from another directory: ```ts server.ts new Elysia().use(htmxUi({ root: "../marketing" })).listen(3000); ``` ## Testing `app.handle()` answers a `Request` in-process, with no port: ```ts server.test.ts import { expect, test } from "bun:test"; import { app } from "./server"; test("serves the home page", async () => { const res = await app.handle(new Request("http://localhost/")); expect(res.status).toBe(200); expect(res.headers.get("Content-Type")).toContain("text/html"); }); ``` ## Build and run In production the plugin serves `dist/`, so build first and set `NODE_ENV`: ```bash Terminal htmx-ui build && NODE_ENV=production bun server.ts ``` See [Deploying](https://susonwaiba.github.io/htmx-ui/docs/servers/deploying) for what to ship, cache headers and Docker. --- title: "Fastify" description: "Serve a built htmx-ui site as Fastify's not-found handler, and render pages and fragments from your routes." url: "/docs/servers/fastify" section: "Servers" --- # Fastify Serve a built htmx-ui site as Fastify's not-found handler, and render pages and fragments from your routes. `htmx-ui-engine/fastify` exports `htmxUi()`, a plugin that makes htmx-ui Fastify's not-found handler. It only sees requests no route of yours matched, so it can be registered anywhere. It is tested with Fastify 5, on Node and Bun. ```bash bun bun add htmx-ui-engine fastify ``` ```bash npm npm install htmx-ui-engine fastify ``` ```bash pnpm pnpm add htmx-ui-engine fastify ``` ```bash yarn yarn add htmx-ui-engine fastify ``` ## Mount it ```ts server.ts import Fastify from "fastify"; import { htmxUi } from "htmx-ui-engine/fastify"; const app = Fastify(); app.register(htmxUi()); // pages, assets, public files, the config's routes, a 404 page app.get("/api/invoices", async () => db.invoices.list()); app.post("/api/invoices", async (request) => db.invoices.create(request.body)); await app.listen({ port: 3000 }); ``` Paths nothing claims get the [404 page](https://susonwaiba.github.io/htmx-ui/docs/servers#not-found): your `pages/404.html`, or htmx-ui's default. Every method reaches the not-found handler, so the config's `routes` answer `hx-post` as well as `hx-get`. > **One not-found handler** Fastify allows one not-found handler per instance, so the plugin conflicts with your own `setNotFoundHandler()` on the same instance, and registering it twice fails. Register it on the root instance, not inside a prefixed plugin. ## Fragments and pages `reply.type("text/html")` sends rendered HTML with the right content type: ```ts server.ts import Fastify from "fastify"; import { createSite } from "htmx-ui-engine"; import { htmxUi } from "htmx-ui-engine/fastify"; const site = await createSite(); const app = Fastify(); app.post<{ Params: { id: string } }>("/api/invoices/:id/refund", async (request, reply) => reply.type("text/html").send(site.fragment("partials/invoice-row.html", { invoice: refund(request.params.id) })), ); app.get("/dashboard", async (request, reply) => reply.type("text/html").send(site.render("/dashboard", { user: currentUser(request) })), ); app.register(htmxUi({ site })); // the same site: templates compiled once await app.listen({ port: 3000 }); ``` Fastify parses JSON bodies itself, but not HTML forms. Add `@fastify/formbody` to read `hx-post` forms in your own routes. The config's `routes` get the request body either way. See [Pages & fragments](https://susonwaiba.github.io/htmx-ui/docs/servers/rendering) for forms, htmx headers and error fragments. ## Options `htmxUi(options)` takes the options of [`createSite()`](https://susonwaiba.github.io/htmx-ui/docs/servers/api#options): `root`, `config`, `context`, `asset`, `cache` and `site`. For example, to serve a site from another directory: ```ts server.ts app.register(htmxUi({ root: "../marketing" })); ``` ## Testing `app.inject()` answers in-process, with no port: ```ts server.test.ts import { expect, test } from "bun:test"; import { app } from "./server"; test("serves the home page", async () => { const res = await app.inject({ method: "GET", url: "/" }); expect(res.statusCode).toBe(200); }); ``` ## Build and run In production the plugin serves `dist/`, so build first and set `NODE_ENV`: ```bash Terminal htmx-ui build && NODE_ENV=production node server.ts ``` Node runs `server.ts` directly from 22.18. See [Deploying](https://susonwaiba.github.io/htmx-ui/docs/servers/deploying) for what to ship, cache headers and Docker. --- title: "Koa" description: "Serve a built htmx-ui site as Koa middleware that answers only what nothing else did, and render pages and fragments from your routes." url: "/docs/servers/koa" section: "Servers" --- # Koa Serve a built htmx-ui site as Koa middleware that answers only what nothing else did, and render pages and fragments from your routes. `htmx-ui-engine/koa` exports `htmxUi()`, middleware that lets the rest of your stack run first and answers only when nothing else did. Its place in the stack doesn't matter. It is tested with Koa 3, on Node and Bun. ```bash bun bun add htmx-ui-engine koa ``` ```bash npm npm install htmx-ui-engine koa ``` ```bash pnpm pnpm add htmx-ui-engine koa ``` ```bash yarn yarn add htmx-ui-engine koa ``` ## Mount it ```ts server.ts import Koa from "koa"; import Router from "@koa/router"; import { htmxUi } from "htmx-ui-engine/koa"; const app = new Koa(); const router = new Router(); router.get("/api/invoices", async (ctx) => { ctx.body = await db.invoices.list(); }); app.use(htmxUi()); // pages, assets, public files, the config's routes, a 404 page app.use(router.routes()); app.listen(3000); ``` The middleware calls `await next()` first. Afterwards, it answers only if the response still has Koa's default `404` status and no body. A route that sets a body, or a status of its own, keeps its response. Paths nothing claims get the [404 page](https://susonwaiba.github.io/htmx-ui/docs/servers#not-found): your `pages/404.html`, or htmx-ui's default. ## Fragments and pages Set `ctx.type = "html"` and put the rendered HTML in `ctx.body`: ```ts server.ts import Koa from "koa"; import Router from "@koa/router"; import { createSite } from "htmx-ui-engine"; import { htmxUi } from "htmx-ui-engine/koa"; const site = await createSite(); const app = new Koa(); const router = new Router(); router.post("/api/invoices/:id/refund", (ctx) => { ctx.type = "html"; ctx.body = site.fragment("partials/invoice-row.html", { invoice: refund(ctx.params.id) }); }); router.get("/dashboard", (ctx) => { ctx.type = "html"; ctx.body = site.render("/dashboard", { user: currentUser(ctx) }); }); app.use(htmxUi({ site })); // the same site: templates compiled once app.use(router.routes()); app.listen(3000); ``` A body parser such as `@koa/bodyparser` works alongside it: the adapter rebuilds the body it read for the config's `routes`. Errors thrown in the adapter propagate like any other, so a `try` / `catch` middleware at the top of the stack sees them. See [Pages & fragments](https://susonwaiba.github.io/htmx-ui/docs/servers/rendering) for forms, htmx headers and error fragments. ## Options `htmxUi(options)` takes the options of [`createSite()`](https://susonwaiba.github.io/htmx-ui/docs/servers/api#options): `root`, `config`, `context`, `asset`, `cache` and `site`. For example, to serve a site from another directory: ```ts server.ts app.use(htmxUi({ root: "../marketing" })); ``` ## Testing Listen on port 0 to get a free port, and close the server when the test ends: ```ts server.test.ts import { expect, test } from "bun:test"; import { app } from "./server"; test("serves the home page", async () => { const server = app.listen(0); await new Promise((resolve) => server.once("listening", resolve)); try { const { port } = server.address() as { port: number }; const res = await fetch(`http://localhost:${port}/`); expect(res.status).toBe(200); } finally { server.close(); } }); ``` ## Build and run In production the middleware serves `dist/`, so build first and set `NODE_ENV`: ```bash Terminal htmx-ui build && NODE_ENV=production node server.ts ``` Node runs `server.ts` directly from 22.18. See [Deploying](https://susonwaiba.github.io/htmx-ui/docs/servers/deploying) for what to ship, cache headers and Docker. --- title: "Express" description: "Mount a built htmx-ui site as Express middleware, and render pages and fragments from your routes." url: "/docs/servers/express" section: "Servers" --- # Express Mount a built htmx-ui site as Express middleware, and render pages and fragments from your routes. `htmx-ui-engine/express` exports `htmxUi()`, Connect-style middleware that serves the built site. It works with Express 4 and 5, and with anything else built on `node:http`: Connect, a bare `http.createServer()`, or [NestJS](https://susonwaiba.github.io/htmx-ui/docs/servers/other#nestjs). Use it for a Node app that already exists. For a new one, [Elysia](https://susonwaiba.github.io/htmx-ui/docs/servers/elysia) or [Fastify](https://susonwaiba.github.io/htmx-ui/docs/servers/fastify) don't care about registration order. ```bash bun bun add htmx-ui-engine express ``` ```bash npm npm install htmx-ui-engine express ``` ```bash pnpm pnpm add htmx-ui-engine express ``` ```bash yarn yarn add htmx-ui-engine express ``` ## Mount it last Middleware runs in order, and htmx-ui answers every request it sees, with a 404 page when nothing matches. Register it **after** your own routes and middleware, or they will never be reached: ```ts server.ts import express from "express"; import { htmxUi } from "htmx-ui-engine/express"; const app = express(); app.get("/api/invoices", async (req, res) => { res.json(await db.invoices.list()); }); app.use(htmxUi()); // last: pages, assets, public files, the config's routes, a 404 page app.listen(3000); ``` Paths nothing claims get the [404 page](https://susonwaiba.github.io/htmx-ui/docs/servers#not-found): your `pages/404.html`, or htmx-ui's default. An error thrown while answering goes to `next(error)`, so Express's error middleware handles it as usual. > **Mount it at the root** The adapter matches a request's full path, prefix included. Mounted with `app.use("/blog", htmxUi())`, it looks for `dist/blog/…` and finds nothing. Mount it without a path, and build the site with its pages under `pages/blog/` if that is where they belong. ## Fragments and pages `res.type("html").send()` sends rendered HTML with the right content type: ```ts server.ts import express from "express"; import { createSite } from "htmx-ui-engine"; import { htmxUi } from "htmx-ui-engine/express"; const site = await createSite(); const app = express(); app.use(express.urlencoded({ extended: false })); app.post("/api/invoices/:id/refund", (req, res) => { res.type("html").send(site.fragment("partials/invoice-row.html", { invoice: refund(req.params.id) })); }); app.get("/dashboard", (req, res) => { res.type("html").send(site.render("/dashboard", { user: currentUser(req) })); }); app.use(htmxUi({ site })); // the same site: templates compiled once app.listen(3000); ``` Body parsers are fine before the adapter: when `express.urlencoded()` or `express.json()` has read a request body, the adapter rebuilds it for the config's `routes`. See [Pages & fragments](https://susonwaiba.github.io/htmx-ui/docs/servers/rendering) for forms, htmx headers and error fragments. ## Options `htmxUi(options)` takes the options of [`createSite()`](https://susonwaiba.github.io/htmx-ui/docs/servers/api#options): `root`, `config`, `context`, `asset`, `cache` and `site`. For example, to serve a site from another directory: ```ts server.ts app.use(htmxUi({ root: "../marketing" })); ``` ## Testing Listen on port 0 to get a free port, and close the server when the test ends: ```ts server.test.ts import { expect, test } from "bun:test"; import { app } from "./server"; test("serves the home page", async () => { const server = app.listen(0); await new Promise((resolve) => server.once("listening", resolve)); try { const { port } = server.address() as { port: number }; const res = await fetch(`http://localhost:${port}/`); expect(res.status).toBe(200); } finally { server.close(); } }); ``` ## Build and run In production the middleware serves `dist/`, so build first and set `NODE_ENV`: ```bash Terminal htmx-ui build && NODE_ENV=production node server.ts ``` Node runs `server.ts` directly from 22.18. See [Deploying](https://susonwaiba.github.io/htmx-ui/docs/servers/deploying) for what to ship, cache headers and Docker. --- title: "Hono" description: "Mount a built htmx-ui site as Hono middleware, and render pages and fragments from your handlers." url: "/docs/servers/hono" section: "Servers" --- # Hono Mount a built htmx-ui site as Hono middleware, and render pages and fragments from your handlers. `htmx-ui-engine/hono` exports `htmxUi()`, middleware that serves the built site. It runs wherever Hono has a filesystem: Bun, Node (with `@hono/node-server`) and Deno. Edge runtimes with no filesystem, such as Cloudflare Workers, can't read `dist/`, so the adapter doesn't work there. ```bash bun bun add htmx-ui-engine hono ``` ```bash npm npm install htmx-ui-engine hono ``` ```bash pnpm pnpm add htmx-ui-engine hono ``` ```bash yarn yarn add htmx-ui-engine hono ``` ## Mount it last Hono runs middleware in registration order, and htmx-ui answers every request it sees, with a 404 page when nothing matches. Register it **after** your own handlers: ```ts server.ts import { Hono } from "hono"; import { htmxUi } from "htmx-ui-engine/hono"; const app = new Hono(); app.get("/api/invoices", (c) => c.json(invoices.list())); app.post("/api/invoices", async (c) => c.json(await invoices.create(await c.req.json()), 201)); app.use(htmxUi()); // last: pages, assets, public files, the config's routes, a 404 page export default app; // Bun and Deno serve the default export's fetch ``` Paths nothing claims get the [404 page](https://susonwaiba.github.io/htmx-ui/docs/servers#not-found): your `pages/404.html`, or htmx-ui's default. Because the middleware answers them, Hono's `app.notFound()` never runs. ## Fragments and pages `c.html()` sends rendered HTML with the right content type: ```ts server.ts import { Hono } from "hono"; import { createSite } from "htmx-ui-engine"; import { htmxUi } from "htmx-ui-engine/hono"; const site = await createSite(); const app = new Hono(); app.post("/api/invoices/:id/refund", (c) => c.html(site.fragment("partials/invoice-row.html", { invoice: refund(c.req.param("id")) })), ); app.get("/dashboard", (c) => c.html(site.render("/dashboard", { user: currentUser(c) }))); app.use(htmxUi({ site })); // the same site: templates compiled once export default app; ``` See [Pages & fragments](https://susonwaiba.github.io/htmx-ui/docs/servers/rendering) for forms (`c.req.parseBody()`), htmx headers and error fragments. ## Options `htmxUi(options)` takes the options of [`createSite()`](https://susonwaiba.github.io/htmx-ui/docs/servers/api#options): `root`, `config`, `context`, `asset`, `cache` and `site`. For example, to serve a site from another directory: ```ts server.ts app.use(htmxUi({ root: "../marketing" })); ``` ## Testing `app.request()` answers in-process, with no port: ```ts server.test.ts import { expect, test } from "bun:test"; import app from "./server"; test("serves the home page", async () => { const res = await app.request("/"); expect(res.status).toBe(200); }); ``` ## Build and run In production the middleware serves `dist/`, so build first and set `NODE_ENV`: ```bash Terminal htmx-ui build && NODE_ENV=production bun server.ts ``` On Node, serve the app with `@hono/node-server`'s `serve(app)`. See [Deploying](https://susonwaiba.github.io/htmx-ui/docs/servers/deploying) for what to ship, cache headers and Docker. --- title: "Bun.serve" description: "Serve a built htmx-ui site from Bun.serve with no framework: your routes in routes, the site in fetch." url: "/docs/servers/bun" section: "Servers" --- # Bun.serve Serve a built htmx-ui site from Bun.serve with no framework: your routes in routes, the site in fetch. Bun's own server needs no adapter. `Bun.serve` checks its `routes` first and calls `fetch` for everything else, and `site.handle()` takes a `Request` and returns a `Response`. So your routes go in `routes` and the site goes in `fetch`. There is nothing to install beyond `htmx-ui-engine`. ## Serve it ```ts server.ts import { createSite } from "htmx-ui-engine"; const site = await createSite(); const server = Bun.serve({ port: 3000, routes: { "/api/invoices": { GET: async () => Response.json(await db.invoices.list()), POST: async (req) => Response.json(await db.invoices.create(await req.json()), { status: 201 }), }, }, // Pages, assets, public files, the config's routes, a 404 page. fetch: (req) => site.handle(req), }); console.log(`Listening on ${server.url}`); ``` `routes` always beat `fetch`, so the order of the two keys doesn't matter. Paths nothing claims get the [404 page](https://susonwaiba.github.io/htmx-ui/docs/servers#not-found): your `pages/404.html`, or htmx-ui's default. ## Fragments and pages ```ts server.ts import { createSite } from "htmx-ui-engine"; const site = await createSite(); const html = (body: string, status = 200) => new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8" } }); Bun.serve({ port: 3000, routes: { "/api/invoices/:id/refund": { POST: (req) => html(site.fragment("partials/invoice-row.html", { invoice: refund(req.params.id) })), }, "/dashboard": (req) => html(site.render("/dashboard", { user: currentUser(req) })), }, fetch: (req) => site.handle(req), }); ``` See [Pages & fragments](https://susonwaiba.github.io/htmx-ui/docs/servers/rendering) for forms (`await req.formData()`), htmx headers and error fragments. Bun.serve's `error(error)` option is the place for an error fragment. ## Testing `site.handle()` is a function from `Request` to `Response`, so a test can call it directly. Start the server on port 0 to test your routes too: ```ts server.test.ts import { expect, test } from "bun:test"; import { createSite } from "htmx-ui-engine"; test("serves the home page", async () => { const site = await createSite(); const res = await site.handle(new Request("http://localhost/")); expect(res.status).toBe(200); }); ``` ## Build and run ```bash Terminal htmx-ui build && NODE_ENV=production bun server.ts ``` See [Deploying](https://susonwaiba.github.io/htmx-ui/docs/servers/deploying) for what to ship, cache headers and Docker. For a framework on top of Bun, [Elysia](https://susonwaiba.github.io/htmx-ui/docs/servers/elysia) is the closest fit. --- title: "Other JavaScript servers" description: "Serve a built htmx-ui site from Deno.serve, node:http, h3 and Nitro, or NestJS, with createSite() or an existing adapter." url: "/docs/servers/other" section: "Servers" --- # Other JavaScript servers Serve a built htmx-ui site from Deno.serve, node:http, h3 and Nitro, or NestJS, with createSite() or an existing adapter. Anything that can hand over a web `Request` can call `site.handle(request)` and send back the `Response`. Anything built on `node:http` can use the [Express adapter](https://susonwaiba.github.io/htmx-ui/docs/servers/express), which is plain `(req, res, next)` middleware. Between the two, most JavaScript servers are covered. Below are the common ones. ## Deno `Deno.serve` takes a function from `Request` to `Response`, which is exactly what `site.handle` is. Deno reads `htmx-ui.config.ts` and `node_modules` as they are: ```ts server.ts import { createSite } from "htmx-ui-engine"; const site = await createSite(); Deno.serve({ port: 3000 }, (req) => { const { pathname } = new URL(req.url); if (pathname === "/api/ping") return Response.json({ ok: true }); return site.handle(req); }); ``` ```bash Terminal htmx-ui build && NODE_ENV=production deno run -A server.ts ``` [Hono](https://susonwaiba.github.io/htmx-ui/docs/servers/hono) runs on Deno too, if you want a router. ## node:http The Express adapter's middleware works on a bare Node server. Call it for the requests your code doesn't answer: ```ts server.ts import { createServer } from "node:http"; import { htmxUi } from "htmx-ui-engine/express"; const site = htmxUi(); createServer((req, res) => { if (req.url === "/api/ping") { res.setHeader("Content-Type", "application/json"); return res.end(JSON.stringify({ ok: true })); } site(req, res, (error) => { res.statusCode = 500; res.end(String(error)); }); }).listen(3000); ``` The same goes for Connect and other `(req, res, next)` stacks: mount it last. ## h3 and Nitro h3 2 handlers get a web `Request` as `event.req` and can return a `Response`. A catch-all route hands the rest to the site. h3's router prefers specific routes, so the order doesn't matter: ```ts server.ts import { H3, serve } from "h3"; import { createSite } from "htmx-ui-engine"; const site = await createSite(); const app = new H3(); app.get("/api/ping", () => ({ ok: true })); app.all("/**", (event) => site.handle(event.req)); // everything else is the built site serve(app, { port: 3000 }); ``` In Nitro, the same handler goes in a catch-all route, `routes/[...].ts`. Nitro 2 is built on h3 1, where `event.req` is Node's request, so convert it first: `site.handle(toWebRequest(event))`. ## NestJS On Nest's default Express platform, the [Express adapter](https://susonwaiba.github.io/htmx-ui/docs/servers/express) does the serving. Don't use `app.use()` for it: Nest runs global middleware before its controllers, so the site would answer every request. When no route matches, Nest throws a `NotFoundException` with the message `Cannot `. An exception filter can catch exactly those and hand them to the site, and leave the `NotFoundException`s your controllers throw alone: ```ts src/htmx-ui.filter.ts import { Catch, NotFoundException, type ArgumentsHost, type ExceptionFilter } from "@nestjs/common"; import type { Request, Response } from "express"; import { htmxUi } from "htmx-ui-engine/express"; const site = htmxUi(); @Catch(NotFoundException) export class HtmxUiFilter implements ExceptionFilter { catch(error: NotFoundException, host: ArgumentsHost) { const req = host.switchToHttp().getRequest(); const res = host.switchToHttp().getResponse(); // Not "no route matched": a controller's own 404, so answer it as Nest would. if (error.message !== `Cannot ${req.method} ${req.originalUrl}`) { return res.status(404).json(error.getResponse()); } site(req, res, (e) => res.status(500).end(String(e))); } } ``` ```ts src/main.ts const app = await NestFactory.create(AppModule); app.useGlobalFilters(new HtmxUiFilter()); await app.listen(3000); ``` To render fragments from controllers, create a site with `createSite()`, pass it to the filter's adapter as `htmxUi({ site })`, and return `site.fragment(...)` from handlers decorated with `@Header("Content-Type", "text/html; charset=utf-8")`. ## Anything else What any integration needs: - Create the site once, at startup: `const site = await createSite()`. - Pass the requests your own code doesn't answer to `site.handle()` as a web `Request`, and send back the `Response` it resolves to: status, headers (including several `Set-Cookie`), body. - Use `site.fragment()` and `site.render()` for HTML, with an HTML content type. The adapters' source in `htmx-ui-engine/src/` is the reference: each is a few dozen lines. See [`createSite()` API](https://susonwaiba.github.io/htmx-ui/docs/servers/api) for every option. --- title: "Django, Rails, Go and others" description: "Use htmx-ui with a backend in any language: serve the built site as static files and render fragments with your own templates, or run htmx-ui next to it." url: "/docs/servers/any-backend" section: "Servers" --- # Django, Rails, Go and others Use htmx-ui with a backend in any language: serve the built site as static files and render fragments with your own templates, or run htmx-ui next to it. htmx works with any server that returns HTML, and many htmx apps are written in Python, Ruby, PHP, Go or Elixir. The adapters are JavaScript, but your backend doesn't have to be. There are two ways to combine them. Pick one by asking who renders the per-request HTML. ## Your backend renders the fragments Build the site with `htmx-ui build` as part of your release, and serve `dist/` as static files. htmx in those pages calls your backend, and your backend answers with fragments written in its own templates, using htmx-ui's classes. The components are CSS classes plus small behaviours that initialise on every swap, so markup from Django, ERB, Blade or Go's `html/template` works the same as markup from Nunjucks. ```html templates/invoices/_row.html (Django) {{ invoice.number }} {{ invoice.status }} ``` Copy the markup for each component from its page in these docs. The classes are the API, and they are the same in any template language. The front server (nginx here) serves the pages with clean URLs and passes the API to your backend: ```nginx nginx.conf server { listen 80; root /srv/site/dist; # Your backend: Django, Rails, Laravel, Go... location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; } location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; try_files $uri =404; } # /about -> about.html, /docs -> docs/index.html location / { try_files $uri $uri.html $uri/index.html =404; } error_page 404 /404.html; } ``` ```txt Caddyfile example.com { root * /srv/site/dist encode gzip handle /api/* { reverse_proxy 127.0.0.1:8000 } handle { try_files {path} {path}.html {path}/index.html file_server } handle_errors 404 { rewrite * /404.html file_server } } ``` Your framework's static file serving works as well as nginx, as long as it maps `/about` to `about.html`. `dist/404.html` is your `pages/404.html`, or htmx-ui's default. > **Nunjucks is close to Jinja, not the same** Nunjucks borrows Jinja's syntax, so Django and Flask templates read alike, and moving a partial between them is mostly mechanical. But filters, tests and macro `caller()` differ, and htmx-ui's `json()`, `svg()`, `icon()` and `asset()` exist only in the engine. Treat the two as separate template sets. ## htmx-ui renders, next to your backend To keep one set of templates, run a small htmx-ui server (any [adapter](https://susonwaiba.github.io/htmx-ui/docs/servers)) next to your backend. It serves the pages and renders the fragments. Your backend stays the source of the data, which the htmx-ui server reads over HTTP, as JSON: ```ts server.ts import { Elysia } from "elysia"; import { createSite } from "htmx-ui-engine"; import { htmxUi } from "htmx-ui-engine/elysia"; const site = await createSite(); const backend = process.env.BACKEND_URL ?? "http://127.0.0.1:8000"; const html = (body: string) => new Response(body, { headers: { "Content-Type": "text/html; charset=utf-8" } }); new Elysia() .get("/invoices/table", async ({ request }) => { // Pass the session cookie along, so the backend knows who is asking. const res = await fetch(`${backend}/api/invoices`, { headers: { cookie: request.headers.get("cookie") ?? "" } }); return html(site.fragment("partials/invoice-table.html", { invoices: await res.json() })); }) .use(htmxUi({ site })) .listen(3000); ``` Put both behind one host, with the proxy sending `/api/` to your backend and everything else to the htmx-ui server. Pages and API then share an origin, cookies and all. This adds a process to deploy. In exchange, every piece of HTML comes from one place. ## Which one? - **Your backend renders the fragments** when most of the HTML is per-request and your team already writes it in your backend's templates. htmx-ui provides the pages, the design system and the components. - **htmx-ui renders next to your backend** when the front end is its own project, or the backend is meant to stay a JSON API. Either way, [mock endpoints](https://susonwaiba.github.io/htmx-ui/docs/servers/deploying#mock-routes) in `htmx-ui.config.ts` let you build the pages before the backend has the routes. --- title: "Components" description: "Every component, grouped by what it is for: forms, actions, navigation, overlays, feedback, layout, data, typography and chat. Each is a set of CSS classes, plus a small behaviour where one is needed." url: "/docs/components" section: "Components" --- # Components Every component, grouped by what it is for: forms, actions, navigation, overlays, feedback, layout, data, typography and chat. Each is a set of CSS classes, plus a small behaviour where one is needed. 61 components in 9 groups. Jump to one: [Forms & inputs 13](#forms) [Actions & menus 9](#actions) [Navigation 5](#navigation) [Overlays 7](#overlays) [Feedback & status 8](#feedback) [Layout & disclosure 5](#layout) [Data display 4](#data) [Typography & media 4](#content) [Chat & AI 6](#chat) ## Forms & inputs Controls that take input and submit with a form, and the fields that label them. - [Checkbox](https://susonwaiba.github.io/htmx-ui/docs/components/checkbox): A box that toggles between checked and not checked, with sizes, states and every field feature. - [Combobox](https://susonwaiba.github.io/htmx-ui/docs/components/combobox): An input with a filtered list of suggestions: groups, multiple picks as chips, a clear button, server search. - [Field](https://susonwaiba.github.io/htmx-ui/docs/components/field): Labels, help text and messages for controls; inline, responsive and grid forms. - [Input](https://susonwaiba.github.io/htmx-ui/docs/components/input): Text inputs, textareas, selects and file pickers with every state. - [Input group](https://susonwaiba.github.io/htmx-ui/docs/components/input-group): Icons, text, buttons, menus and spinners inside an input's border. - [Input OTP](https://susonwaiba.github.io/htmx-ui/docs/components/input-otp): A one-time code or PIN in a row of slots: 4-digit authenticator codes, digits or letters, groups, paste and SMS autofill, sizes and states. - [Label](https://susonwaiba.github.io/htmx-ui/docs/components/label): An accessible label for any control, wrapping checkboxes and radios, or as a choice card. - [Password input](https://susonwaiba.github.io/htmx-ui/docs/components/password-input): A password field with a show / hide toggle: eye icon or text, a checkbox, Caps Lock warning, every input state. - [Radio group](https://susonwaiba.github.io/htmx-ui/docs/components/radio-group): Radio buttons as cards or inline rows: one choice of a few, submitting with the form. - [Select](https://susonwaiba.github.io/htmx-ui/docs/components/select): A list of options in a popup opened from a button: searchable, keyboard driven, styled like an input. - [Slider](https://susonwaiba.github.io/htmx-ui/docs/components/slider): Pick a value, a range or several values on a track: native range inputs, vertical too, with every form state. - [Switch](https://susonwaiba.github.io/htmx-ui/docs/components/switch): An on/off setting that applies at once: a sliding track on a real checkbox, no JavaScript. - [Textarea](https://susonwaiba.github.io/htmx-ui/docs/components/textarea): Multi-line text that grows as you type: field, character count, every state, and Ctrl/⌘+Enter to submit. ## Actions & menus Buttons, toggles and menus that do something. - [Button](https://susonwaiba.github.io/htmx-ui/docs/components/button): Actions in every variant, size and state, including loading. - [Button group](https://susonwaiba.github.io/htmx-ui/docs/components/button-group): Join buttons, inputs and menus: split buttons, toolbars, vertical groups. - [Clipboard](https://susonwaiba.github.io/htmx-ui/docs/components/clipboard): Copy buttons and copy tags that work on every browser: icon, label or both, a green tick when copied, every button variant and size. - [Command](https://susonwaiba.github.io/htmx-ui/docs/components/command): A command menu for search and quick actions: filtering, keyboard, groups, shortcuts, inline or in a ⌘K dialog. - [Dropdown](https://susonwaiba.github.io/htmx-ui/docs/components/dropdown): A menu of links, actions and options that opens from a button, with checkbox and radio items, shortcuts and submenus. - [Menubar](https://susonwaiba.github.io/htmx-ui/docs/components/menubar): A persistent row of menus, as in desktop apps: submenus, checkbox and radio items, icons and shortcuts. - [Scroll button](https://susonwaiba.github.io/htmx-ui/docs/components/scroll-button): A floating button that jumps back to the bottom or top of a container or the page. - [Toggle](https://susonwaiba.github.io/htmx-ui/docs/components/toggle): A two-state button, on or off, in any button style. - [Toggle group](https://susonwaiba.github.io/htmx-ui/docs/components/toggle-group): A set of toggle buttons: pick one, at most one, or many. ## Navigation Moving between pages and between views of a page. - [Breadcrumb](https://susonwaiba.github.io/htmx-ui/docs/components/breadcrumb): The path to the current page: custom separators, dropdowns, and collapsing to an ellipsis when space runs out. - [Navigation menu](https://susonwaiba.github.io/htmx-ui/docs/components/navigation-menu): A site's main navigation: links, and buttons that open panels of links, from simple lists to mega menus. - [Pagination](https://susonwaiba.github.io/htmx-ui/docs/components/pagination): Links to the pages of a long list, with previous and next, page numbers or icons only. - [Sidebar](https://susonwaiba.github.io/htmx-ui/docs/components/sidebar): Composable app navigation: groups, menus, badges and actions, collapsing to icons or off-canvas, with a mobile panel. - [Tabs](https://susonwaiba.github.io/htmx-ui/docs/components/tabs): Layered panels shown one at a time: line, pill and outline styles, icons, disabled tabs, vertical, synced and remembered. ## Overlays Content that opens above the page: modals, panels, popups and hints. - [Alert dialog](https://susonwaiba.github.io/htmx-ui/docs/components/alert-dialog): A modal that interrupts with important content and waits for an answer: small size, media and destructive actions. - [Dialog](https://susonwaiba.github.io/htmx-ui/docs/components/dialog): A modal window over the page or another dialog: close button or outside click, scrolling content, sticky footer, htmx fragments. - [Drawer](https://susonwaiba.github.io/htmx-ui/docs/components/drawer): A panel on an edge of the screen that can be swiped away: four directions, a handle, nested drawers, a responsive dialog. - [Hover card](https://susonwaiba.github.io/htmx-ui/docs/components/hover-card): A card that previews what is behind a link when a mouse rests on it or the link gets keyboard focus. - [Popover](https://susonwaiba.github.io/htmx-ui/docs/components/popover): A floating panel of rich content that opens from a button: a header, alignment, and forms. - [Sheet](https://susonwaiba.github.io/htmx-ui/docs/components/sheet): A dialog that slides in from an edge of the screen: four sides, sizes, scrolling content, htmx fragments. - [Tooltip](https://susonwaiba.github.io/htmx-ui/docs/components/tooltip): A short hint on hover or keyboard focus, on any side, with no JavaScript needed to show it. ## Feedback & status Messages, progress, loading and empty states, and labels for status. - [Alert](https://susonwaiba.github.io/htmx-ui/docs/components/alert): Callouts for info, success, warnings and errors. - [Badge](https://susonwaiba.github.io/htmx-ui/docs/components/badge): Small status labels and counters. - [Empty](https://susonwaiba.github.io/htmx-ui/docs/components/empty): An empty state with an icon, a message and a way forward. - [Loader](https://susonwaiba.github.io/htmx-ui/docs/components/loader): Pure-CSS loading indicators: dots, waves, pulses and animated text. - [Progress](https://susonwaiba.github.io/htmx-ui/docs/components/progress): A bar showing how far a task has got, with an optional label, value and help text. - [Skeleton](https://susonwaiba.github.io/htmx-ui/docs/components/skeleton): Placeholders shaped like loading content: any shape, text, avatars, cards, lists, tables and forms. - [Spinner](https://susonwaiba.github.io/htmx-ui/docs/components/spinner): Loading indicator from any icon or pure CSS; works in buttons, badges and inputs. - [Toast](https://susonwaiba.github.io/htmx-ui/docs/components/toast): Stacking, swipeable notifications: success, info, warning, error and loading, actions, promises and server toasts. ## Layout & disclosure Containers that group, separate, reveal or scroll through content. - [Accordion](https://susonwaiba.github.io/htmx-ui/docs/components/accordion): Stacked headings that reveal sections; one or many open, no JavaScript. - [Card](https://susonwaiba.github.io/htmx-ui/docs/components/card): Header, content and footer, with media, an action slot and scrolling content. - [Carousel](https://susonwaiba.github.io/htmx-ui/docs/components/carousel): Slides that drag, swipe and snap with momentum (Embla): item sizes, spacing, vertical, options and plugins. - [Collapsible](https://susonwaiba.github.io/htmx-ui/docs/components/collapsible): A button that shows and hides a panel: extra settings, nested file trees, or
    with no JavaScript. - [Separator](https://susonwaiba.github.io/htmx-ui/docs/components/separator): A horizontal or vertical line between content, semantic or purely visual. ## Data display Rows, tables and people: structured content to scan. - [Avatar](https://susonwaiba.github.io/htmx-ui/docs/components/avatar): An image with a fallback for a user: sizes, status badges, groups with a count, and menu triggers. - [Item](https://susonwaiba.github.io/htmx-ui/docs/components/item): A row of content with media, a title, a description and actions, alone or in a list. - [Table](https://susonwaiba.github.io/htmx-ui/docs/components/table): Readable data tables with optional hover rows. - [Timeline](https://susonwaiba.github.io/htmx-ui/docs/components/timeline): Events in chronological order: dots, icons or avatars, colours, sizes, vertical or horizontal, alternating sides. ## Typography & media Long-form text, code, keys and icons. - [Code block](https://susonwaiba.github.io/htmx-ui/docs/components/code-block): Highlighted source with a copy button and package-manager tabs. - [Icon](https://susonwaiba.github.io/htmx-ui/docs/components/icon): SVG icons inlined at build time, or linked as images. - [Kbd](https://susonwaiba.github.io/htmx-ui/docs/components/kbd): Keyboard keys and shortcuts, on their own or in buttons, tooltips and input groups. - [Text](https://susonwaiba.github.io/htmx-ui/docs/components/text): Long-form content with .prose (Tailwind Typography), plus helpers for single elements. ## Chat & AI Conversations, attachments and assistant replies. - [Attachment](https://susonwaiba.github.io/htmx-ui/docs/components/attachment): A file, image, video or audio attachment with upload states, sizes, orientation and scrolling groups. - [Chain of thought](https://susonwaiba.github.io/htmx-ui/docs/components/chain-of-thought): A sequence of steps on a connecting line, each folding its details away: reasoning traces, tool calls and process logs. - [Message](https://susonwaiba.github.io/htmx-ui/docs/components/message): A chat message with avatar, header, footer and alignment; groups, attachments, and reasoning and tool calls for AI chat. - [Message scroller](https://susonwaiba.github.io/htmx-ui/docs/components/message-scroller): Scrolling for a streaming chat transcript: new questions rise to the top, replies grow beneath them, and the view moves only when the reader does. - [Prompt input](https://susonwaiba.github.io/htmx-ui/docs/components/prompt-input): The text box of an AI chat: a growing textarea, tools, send and stop, @ mentions and / commands. - [Reasoning](https://susonwaiba.github.io/htmx-ui/docs/components/reasoning): A collapsible for AI reasoning that opens while it streams and closes, with its duration, when it ends. ## Anatomy of a component Each component has its own directory, `htmx-ui/components//` (`packages/ui/src/components//` in the repository), holding: - `.css`: classes in `@layer components`, built from token utilities with `@apply`. - `.ts` (optional): an `init(root)` function that wires up `[data-]` elements once each, with `.test.ts` beside it. - `.html` (optional): a Nunjucks macro for markup that is tedious to write by hand. --- title: "Checkbox" description: "A box that toggles between checked and not checked, with sizes, states and every field feature." url: "/docs/components/checkbox" section: "Components" --- # Checkbox A box that toggles between checked and not checked, with sizes, states and every field feature. ```html
    ``` A [toggle](https://susonwaiba.github.io/htmx-ui/docs/components/toggle) is a button that switches something on or off on the spot. A checkbox is part of a form: it takes a `name` and a `value`, submits with the rest, and is reset by the form. ## Usage - Add `.checkbox` to a native ``. Everything else is plain HTML: `checked`, `required`, `name`, `value`, `disabled`. - Wrap it in a [field](https://susonwaiba.github.io/htmx-ui/docs/components/field) with `.field-horizontal`: the box first, then `.field-content` holding the `.field-label` and an optional `.field-description`. The label is clickable, so give it the `for` that matches the box's `id`. - Put related boxes in a `
    ` with a ``, so a screen reader announces the group's question. - A `required` checkbox must be checked to submit, and its label gets the field's asterisk. ```html
    Notifications

    When someone @mentions you.

    ``` ## Sizes `.checkbox-sm` and `.checkbox-lg` match the heights of `.input-sm` and `.input-lg`. ```html
    ``` ## States ### Disabled `disabled` fades the box; inside a field the label and description fade with it. ```html

    Available on the Pro plan.

    ``` ### Error, warning, success Set `aria-invalid="true"` for an error, which turns the border red and the field's label with it, or add `.checkbox-warning` or `.checkbox-success` for the softer states. Pair each with a [field message](https://susonwaiba.github.io/htmx-ui/docs/components/field#messages) linked with `aria-describedby`. ```html

    You must accept the terms to continue.

    Highly recommended for admin accounts.

    Done.

    ``` ## Partly selected A box that controls a group is **indeterminate** when some, but not all, of its children are checked. The state is a DOM property rather than an attribute, so markup it with `data-indeterminate`: a small behaviour sets it on load, paints a dash instead of the tick, and clears it when you click the box. ```html
    Services to monitor
    ``` Nothing checks the children for you: that is server state, so keep the box in step in your own code, or read the children on `change` and set the property: ```js app.js const all = document.querySelector("#all"); const children = [...document.querySelectorAll("[data-child]")]; const sync = () => { const checked = children.filter((box) => box.checked).length; all.indeterminate = checked > 0 && checked < children.length; all.checked = checked === children.length; }; all.addEventListener("change", () => children.forEach((box) => (box.checked = all.checked))); children.forEach((box) => box.addEventListener("change", sync)); ``` ## With htmx Checkboxes submit like any other input, so trigger on `change` and send the state in `hx-vals`: ```html

    ``` ## Nunjucks macro If your templates use Nunjucks, the `checkbox` macro writes the field markup, with the description optional and every attribute passed through `attrs`: ```jinja page.html {% from "components/checkbox/checkbox.html" import checkbox %} {{ checkbox("terms", "I accept the terms", required=true) }} {{ checkbox("digest", "Weekly digest", description="A summary every Monday.", checked=true) }} {{ checkbox("alerts", "Deploy alerts", size="sm", state="warning", attrs={"hx-post": "/api/settings"}) }} ``` ## Reference | Class / attribute | Description | | --- | --- | | `.checkbox` | Native checkbox. Take checked, name, value, required and disabled as usual. | | `.checkbox-sm / -lg` | Matches the height of .input-sm / .input-lg. | | `data-indeterminate` | Paints the mixed state (behaviour: checkbox.ts); cleared when the box is clicked. | | `aria-invalid="true"` | Error border, and a red label in a field. So does :user-invalid. | | `.checkbox-warning / -success` | Warning or success border. | | `checkbox(id, label, …)` | Macro: the whole field. description, checked, indeterminate, disabled, required, size, state, attrs. | --- title: "Combobox" description: "An input with a list of suggestions that filters as you type: groups, multiple picks as chips, a clear button and server-side search." url: "/docs/components/combobox" section: "Components" --- # Combobox An input with a list of suggestions that filters as you type: groups, multiple picks as chips, a clear button and server-side search. ```html
    ``` A combobox is a text input that suggests values: the user can type to narrow a long list, or open it and pick. Use a [select](https://susonwaiba.github.io/htmx-ui/docs/components/select) when the list is short enough to scan, and a combobox when typing is the faster way in. ## Markup - Wrap everything in `.combobox` with `data-combobox`. - The input has `data-combobox-input` and a name for screen readers (`aria-label`, or a `
    ``` ## Submenus Wrap an item and a nested menu in `.dropdown-sub`. The item gets `aria-haspopup="menu"` and `aria-expanded="false"`, and a `chevron-right` icon, which sits at the right edge. The submenu opens beside the item when it is hovered, clicked, or focused and `→`, `Enter` or `Space` is pressed; `←` and `Esc` close it. Near the edge of the window it opens to the left or upwards instead. Submenus can nest. ```html ``` ## Checkbox items An item with `role="menuitemcheckbox"` and `aria-checked` toggles on and off. A check shows in the inset slot on the left; add `.dropdown-item-inset` to the plain items around it so their text lines up. ```html ``` ## Radio items Items with `role="menuitemradio"` inside a `role="group"` form a set: checking one unchecks the others in the same group (or, without a group, the others directly in the same menu). Label each group: `aria-label`, or `aria-labelledby` pointing at a `.dropdown-label`. ```html ``` Choosing a checkbox or radio item closes the menu, like any other item. To keep it open so several can be set in a row, add `data-keep-open` to the item, to its group or to the whole menu. ## Icons Put an `icon()` before the text. Icons in items are muted and sized to match; a submenu's chevron and a trailing icon sit at the right edge. `.dropdown-item-destructive` marks an item that deletes or discards something. ```html ``` ## Vertical `aria-orientation="vertical"` stacks the triggers and opens each menu to their right (to the left near the edge of the window). `↑` `↓` then move between the triggers, `→` opens a menu and `←` closes it. ```html ``` ## With htmx Items are buttons and links, so they make htmx requests like any other: give an item `hx-post` and the menu closes as the request starts. Checkbox and radio items fire a bubbling `menu:change` event when they change, with `detail.checked`, `detail.name` and `detail.value`. Give them a `name` (and a `value`; a checkbox item defaults to `on`) and each keeps a hidden input inside it in step, so a surrounding form submits the checked ones like real checkboxes and radios. Here the form posts on every change with `hx-trigger="menu:change"`: ```html
    ``` ## Keyboard | Key | Action | | --- | --- | | `Tab` | Into the menubar (the last trigger used), or on out of it, closing any menu | | `←` `→` on a trigger | Previous / next trigger (wraps); if a menu is open, opens that trigger's menu instead | | `Home` `End` on a trigger | First / last trigger | | `↓` `Enter` `Space` on a trigger | Open its menu on the first item | | `↑` on a trigger | Open its menu on the last item | | `↓` `↑` `Home` `End` in a menu | Next / previous / first / last item, skipping disabled ones | | Letters | Next trigger or item starting with the typed letters | | `Enter` `Space` in a menu | Choose the item (toggle a checkbox, check a radio) and close; open a submenu | | `→` in a menu | Open the submenu; on any other item, open the next top-level menu | | `←` in a menu | Close the submenu; in a top-level menu, open the previous one | | `Esc` | Close the innermost menu and return focus to what opened it | In a vertical menubar, `↑` `↓` move between triggers, `→` opens a menu and `←` in a top-level menu closes it. With the mouse: click a trigger to open or close its menu; while one is open, hovering another trigger switches to it. Clicking outside closes it. ## Accessibility - The markup follows the [WAI-ARIA menubar pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/): `menubar`, `menuitem`, `menu`, `menuitemcheckbox`, `menuitemradio`, `group` and `separator` roles, with `aria-haspopup`, `aria-expanded`, `aria-controls` and `aria-checked` kept in step. - Give the menubar an `aria-label`, and each radio group a label. Name icon-only items with `aria-label`. - Only one trigger is in the tab order (roving `tabindex`), so `Tab` moves past the whole bar in one step. - Disabled items use `aria-disabled="true"`: screen readers still announce them, but they can't be chosen and the arrow keys skip them. - A shortcut's text is read as part of the item's name. Add `aria-keyshortcuts` only for shortcuts your page really handles. ## Reference | Class / attribute | Description | | --- | --- | | `.menubar` | The bar: a row of triggers in a bordered surface. Needs role="menubar", data-menubar and aria-label. | | `.menubar[aria-orientation="vertical"]` | Stacks the triggers; menus open to the side. | | `.menubar-menu` | Wraps one trigger and its menu (positioning). | | `.menubar-trigger` | Top-level item: role="menuitem", aria-haspopup="menu", aria-expanded. Highlighted while its menu is open. | | `.dropdown-menu` | Each menu panel (role="menu", hidden). All the dropdown item classes work inside it; see Dropdown. | | `.dropdown-sub` | Wraps a submenu trigger (aria-haspopup="menu") and its nested menu. | | `data-keep-open` | On an item, group or menu: choosing an item doesn't close the menu. | | `name / value` | On checkbox and radio items: kept in a hidden input so a surrounding form submits them. | | `menu:change` | Bubbling event from a checkbox or radio item that changed; detail { checked, name, value }. | | `data-flip-x / data-flip-y` | Set on a menu that would leave the window: it opens the other way. | | `[data-menubar]` | Behaviour: roving focus, keyboard navigation, opening and closing, hover switching. | --- title: "Scroll button" description: "A floating button that appears once the reader scrolls away from an edge and jumps back to the bottom or the top, of a scroll container or the whole page." url: "/docs/components/scroll-button" section: "Components" --- # Scroll button A floating button that appears once the reader scrolls away from an edge and jumps back to the bottom or the top, of a scroll container or the whole page. ```html
    1. Event 1: build step finished12:01
    2. Event 2: build step finished12:02
    3. Event 3: build step finished12:03
    4. Event 4: build step finished12:04
    5. Event 5: build step finished12:05
    6. Event 6: build step finished12:06
    7. Event 7: build step finished12:07
    8. Event 8: build step finished12:08
    9. Event 9: build step finished12:09
    10. Event 10: build step finished12:10
    11. Event 11: build step finished12:11
    12. Event 12: build step finished12:12
    13. Event 13: build step finished12:13
    14. Event 14: build step finished12:14
    15. Event 15: build step finished12:15
    16. Event 16: build step finished12:16
    17. Event 17: build step finished12:17
    18. Event 18: build step finished12:18
    19. Event 19: build step finished12:19
    20. Event 20: build step finished12:20
    21. Event 21: build step finished12:21
    22. Event 22: build step finished12:22
    23. Event 23: build step finished12:23
    24. Event 24: build step finished12:24
    25. Event 25: build step finished12:25
    26. Event 26: build step finished12:26
    27. Event 27: build step finished12:27
    28. Event 28: build step finished12:28
    29. Event 29: build step finished12:29
    30. Event 30: build step finished12:30
    31. Event 31: build step finished12:31
    32. Event 32: build step finished12:32
    33. Event 33: build step finished12:33
    34. Event 34: build step finished12:34
    35. Event 35: build step finished12:35
    36. Event 36: build step finished12:36
    37. Event 37: build step finished12:37
    38. Event 38: build step finished12:38
    39. Event 39: build step finished12:39
    40. Event 40: build step finished12:40
    ``` ## Markup - The button is an ordinary icon [`.btn`](https://susonwaiba.github.io/htmx-ui/docs/components/button) plus `.scroll-button`. Give it `type="button"` and an `aria-label` such as "Scroll to bottom". - `data-scroll-button` adds the behaviour. Its value is the edge it jumps to: `bottom` (the default) or `top`. - While its scroll target is near that edge the button is hidden: `data-state="hidden"` fades and shrinks it, and `inert` takes it out of the tab order and the accessibility tree. Once the reader scrolls away it turns `data-state="visible"`. It stays in the DOM throughout. - It follows the content too: when messages are appended or grow, the button appears without a scroll event, so a chat's "jump to bottom" shows up as replies stream in. ## In a scroll container Lay the button over the box it scrolls: wrap both in a `relative` element, mark the box `data-scroll-container` and add `.scroll-button-float`, which centres the button at the bottom of the wrapper (at the top for a `top` button). Give the box `tabindex="0"` so keyboard users can scroll it; when the button hides under the focus, focus moves back to the box. ```html

    Can you summarise the release notes?

    Sure: the release adds a loader, a scroll button and a faster build, and fixes three bugs in the docs search.

    Can you summarise the release notes?

    Sure: the release adds a loader, a scroll button and a faster build, and fixes three bugs in the docs search.

    Can you summarise the release notes?

    Sure: the release adds a loader, a scroll button and a faster build, and fixes three bugs in the docs search.

    Can you summarise the release notes?

    Sure: the release adds a loader, a scroll button and a faster build, and fixes three bugs in the docs search.

    Can you summarise the release notes?

    Sure: the release adds a loader, a scroll button and a faster build, and fixes three bugs in the docs search.

    Can you summarise the release notes?

    Sure: the release adds a loader, a scroll button and a faster build, and fixes three bugs in the docs search.

    ``` The button finds its target in this order: `data-scroll-target`; the closest `[data-scroll-container]` around it; a `[data-scroll-container]` beside it; the nearest ancestor with `overflow-y: auto` or `scroll`; otherwise the page. Point it anywhere with a selector: ```html
    …
    ``` ## Back to top of the page Without a container around it, the button scrolls the page. `.scroll-button-fixed` pins it to the bottom-right corner of the viewport (bottom-left in right-to-left text). This page has one: scroll down and it appears in the corner. ```html ``` ## Options - `data-scroll-button="bottom" | "top"`: the edge to jump to (default `bottom`). - `data-scroll-target="#selector"`: the element to scroll, when it isn't found from the markup. - `data-scroll-threshold="200"`: how far from the edge, in pixels, the reader must scroll before the button shows (default 200). - `data-scroll-behavior="smooth" | "instant"`: how it scrolls (default `smooth`; always instant when the reader prefers reduced motion). A text button works as well as an icon one: anything with `.scroll-button` fades in and out. ```html

    Line 1 of the build output

    Line 2 of the build output

    Line 3 of the build output

    Line 4 of the build output

    Line 5 of the build output

    Line 6 of the build output

    Line 7 of the build output

    Line 8 of the build output

    Line 9 of the build output

    Line 10 of the build output

    Line 11 of the build output

    Line 12 of the build output

    Line 13 of the build output

    Line 14 of the build output

    Line 15 of the build output

    Line 16 of the build output

    Line 17 of the build output

    Line 18 of the build output

    Line 19 of the build output

    Line 20 of the build output

    Line 21 of the build output

    Line 22 of the build output

    Line 23 of the build output

    Line 24 of the build output

    ``` ## JavaScript `initScrollButton(root)` runs with the other components, on load and on content htmx inserts. The helpers it uses are exported from its module too, for a "scroll to bottom" after your own updates: ```ts import { distanceFromEdge, scrollToEdge } from "htmx-ui"; const log = document.querySelector("#log")!; // Keep following new lines, unless the reader has scrolled up to read. const following = distanceFromEdge(log, "bottom") < 40; log.insertAdjacentHTML("beforeend", "

    New line

    "); if (following) scrollToEdge(log, "bottom", "instant"); scrollToEdge(null, "top"); // null is the page: smooth, or instant under reduced motion ``` ## Reference | Class | Description | | --- | --- | | `.scroll-button` | Fades and shrinks out while data-state="hidden", back in when visible. | | `.scroll-button-float` | Absolutely centred at the bottom of the nearest positioned ancestor (top for a top button). | | `.scroll-button-fixed` | Fixed to the viewport's bottom-right corner. | | `[data-scroll-button="bottom" \| "top"]` | Behaviour: shows away from that edge, scrolls to it on click. | | `[data-scroll-container]` | Marks the element a nearby scroll button scrolls. | | `[data-scroll-target]` | A selector for the element to scroll. | | `[data-scroll-threshold]` | Distance from the edge, in px, before the button shows (200). | | `[data-scroll-behavior]` | smooth (default) or instant. | | `[data-state="visible" \| "hidden"]` | Set by the behaviour; hidden is also inert. | | Function | Description | | --- | --- | | `initScrollButton(root = document)` | Initialises every [data-scroll-button] under root (idempotent). | | `scrollToEdge(target, edge = "bottom", behavior?)` | Scrolls an element, or the page for null, to its top or bottom. | | `distanceFromEdge(target, edge)` | How far, in px, the element (or the page) is from that edge. | | `scrollTargetOf(button)` | The element a scroll button scrolls, or null for the page. | --- title: "Toggle" description: "A two-state button that is either on or off. Any button variant can be a toggle." url: "/docs/components/toggle" section: "Components" --- # Toggle A two-state button that is either on or off. Any button variant can be a toggle. ```html ``` ## Usage - Add `data-toggle` and `aria-pressed="false"` (or `"true"` to start on) to any `.btn`. - Each click flips `aria-pressed`, which screen readers announce as "pressed" or "not pressed". Styles key off the same attribute. - The button fires a bubbling `toggle:change` event with `detail.pressed`. - Keep the label the same in both states. "Bold" pressed reads correctly; switching the text to "Not bold" doesn't. ## Any button `.btn-toggle` is the dedicated look: muted when off, filled when on. Every other variant works too: ghost and outline fill when on, secondary turns the brand colour, and primary and danger are outlined when off and solid when on. Click them: ```html ``` ## Sizes and shapes ```html ``` ## Disabled ```html ``` ## Saving with htmx Trigger the request on `toggle:change` rather than `click`, so it runs after `aria-pressed` has flipped, and send the new state with `hx-vals`. ```html

    ``` ## Reference | Class / attribute | Description | | --- | --- | | `.btn-toggle` | Toggle look: muted when off, filled when on. Combine with .btn-outline for a bordered one. | | `data-toggle` | Flips aria-pressed on click and fires toggle:change { pressed }. | | `aria-pressed` | "true" or "false". Drives the pressed style on every button variant. | --- title: "Toggle group" description: "A set of two-state buttons. Pick exactly one, at most one, or any number." url: "/docs/components/toggle-group" section: "Components" --- # Toggle group A set of two-state buttons. Pick exactly one, at most one, or any number. ```html
    ``` Use a toggle group for buttons that **switch a state**. For buttons that perform actions, use a [button group](https://susonwaiba.github.io/htmx-ui/docs/components/button-group). ## Usage - Wrap the buttons in `.toggle-group` with `data-toggle-group`, `role="group"` and an `aria-label`. - Each button gets `aria-pressed`, and a `value` to identify it. They don't need `data-toggle`; the group handles them. - The group is one tab stop. `←` `→` `↑` `↓` `Home` `End` move between buttons; `Space` or `Enter` presses. - Every change fires a bubbling `toggle-group:change` event with `detail.value`: the pressed buttons' values. ## Single `data-toggle-group="single"`: pressing a button releases the others. Pressing the pressed one releases it, leaving none. Add `data-toggle-group-required` to keep exactly one pressed, as in the alignment example at the top. ```html
    ``` ## Multiple `data-toggle-group="multiple"`: each button toggles on its own. ```html
    ``` ## Joined or spaced A `.toggle-group` spaces its buttons. Add `.btn-group` to join them like a [button group](https://susonwaiba.github.io/htmx-ui/docs/components/button-group), with all of its options: sizes, vertical, separators. ```html
    ``` ## Vertical ```html
    ``` ## Disabled Disabled buttons are skipped by clicks and arrow keys. ```html
    ``` ## With htmx Buttons with a `name` send their `value` with the request they trigger, so each button can post the choice by itself: ```html

    ``` ## Reference | Class / attribute | Description | | --- | --- | | `.toggle-group` | Spaced row of toggle buttons. Add .btn-group to join them. | | `.toggle-group-vertical` | Stacks the buttons. | | `data-toggle-group="single"` | Pressing a button releases the others. | | `data-toggle-group="multiple"` | Buttons toggle independently. | | `data-toggle-group-required` | With single: one button always stays pressed. | | `toggle-group:change` | Event fired on the group; detail.value lists the pressed buttons' values. | --- title: "Breadcrumb" description: "Displays the path to the current resource using a hierarchy of links." url: "/docs/components/breadcrumb" section: "Components" --- # Breadcrumb Displays the path to the current resource using a hierarchy of links. ```html ``` ## Markup - A `

    ``` ## Collapsed `.breadcrumb-ellipsis` stands in for steps left out of a long path. Make it the trigger of a dropdown that lists them, and give it an `aria-label`. ```html ``` ## Collapsing when it doesn't fit With `data-breadcrumb` on the `