pha = PatternFly HTMX Alpine.js Live component docs & demos: https://sitenetsoft.org/quarkus-pha (static build — for the fully interactive version, run the showcase locally)
A Quarkus extension that delivers a frontend component library with no SPA framework — PatternFly v6 components written as Qute templates that the server renders into plain HTML, made interactive with Alpine.js, with HTMX fetching partial updates when a page wants them. No React, no virtual DOM, no build step for consumers.
The contract: the server renders the HTML, Alpine reacts to it locally, and HTMX swaps in whatever the server renders next. JSON never reaches the browser to be templated there — Quarkus is the backend-for-frontend (BFF), so any UI change that involves server state arrives as server-rendered HTML: an HTMX fragment swap or an ordinary full page load, whichever fits. Purely local interactions — an open menu, a switched tab — stay in Alpine and never leave the browser.
| Layer | Technology |
|---|---|
| Design system | PatternFly v6 (CSS + design tokens only) |
| Server-side rendering | Qute (Quarkus-native templates) |
| Partial page updates | HTMX |
| Local interactivity | Alpine.js |
| Data viz / maps | Apache ECharts, D3.js, MapLibre GL |
| Rich widgets | Monaco Editor (code), Quill (rich text), Video.js (video), Cytoscape.js (topology) |
| Icons | Font Awesome Free, PatternFly pficons |
Design tokens here means PatternFly's named CSS custom properties
(--pf-t--global--…) for color, spacing, and typography — the design
system's values expressed as variables, nothing to do with AI tokens.
Not because SPAs are bad — because for server-rendered business UIs, the SPA architecture solves problems this stack doesn't have, and charges for them anyway.
1. One state, not two. A SPA framework keeps a client-side copy of server state — fetched as JSON, cached in stores, invalidated, and re-synchronized on every mutation. The developer manages two state machines and the drift between them. Here there is nothing to synchronize: the DOM the server rendered is the application state (HATEOAS — hypermedia as the engine of application state). When state changes, the server renders the new HTML and HTMX swaps it in — or, where a page prefers, an ordinary full-page reload does the same job; partial swaps are optional. Alpine holds only throwaway view state — "is this menu open" — that no one needs to reconcile with the backend.
2. Business logic lives once, on the server. Because React and Angular own a frontend state, the rules that govern that state — validation, permissions, what's visible when — end up implemented twice: once on the server, once in the SPA framework. Two implementations drift, and the JSON API between them becomes a second public interface to secure and version. In this stack the server is the only place business logic exists; the browser receives its conclusions as HTML.
3. Lighter, faster, easier to learn. htmx is ~16 kB gzipped and dependency-free; Alpine.js is another ~16 kB gzipped and its entire API is 15 attributes, 6 properties and 2 methods — both libraries together ship ~33 kB gzipped, before React itself (let alone an app bundle) has loaded. The learning curve is "attributes in your HTML", not a framework's component lifecycle, hooks rules, and toolchain. And the end-user's machine does less: no bundle parse, no hydration, no client-side render — first paint is the page the server sent. The best public data point is Contexte's production port from React to htmx: 67 % less code, 96 % fewer JS dependencies, 50–60 % faster time-to-interactive, 46 % lower browser memory use, with no loss in user experience.
Two more that follow from the architecture:
- No frontend build step. Consumers add a Gradle dependency and write Qute. No Node toolchain, no bundler, no npm tree to audit.
- Immune to framework churn. Tiny, stable APIs mean no framework-major migration every couple of years — and PatternFly is consumed as CSS + design tokens only, so its React layer's churn never reaches this stack.
The honest trade-off: highly offline, optimistic-UI, or editor-like apps (think Figma, not dashboards) genuinely benefit from a client-side framework. This stack targets the other 90% of business UIs — data-driven pages, forms, tables, dashboards — where the server is already the source of truth.
Four views, biggest picture first. Regenerate with bash scripts/diagrams.sh
(sources in docs/diagrams/).
quarkus-pha is a Quarkus extension a consumer application depends on. The browser receives server-rendered PatternFly HTML; HTMX fetches and swaps fragments, Alpine.js reacts locally. There is no SPA framework and no client-side routing.
The runtime module ships Qute fragment templates, the typed Java component models,
the icons: resolver, and all static assets served under /web. The deployment
module runs at build time only.
HTMX is optional. Components render identically in an ordinary full-page Qute response, and interactivity that needs no server data afterwards — tabs, toggles, menus — is pure Alpine local state with zero round-trips.
The core contract: the server always renders the HTML. JSON never becomes HTML in the browser.
Components are Qute includes. Simple ones take parameters:
{#include components/feedback/alert variant="success" titleText="Saved" /}Composite components (card, drawer, modal, data-list) are template families — a thin root plus per-section sub-templates composed in the root include's main block, mirroring how PatternFly's own React subcomponents compose:
{#include components/data-display/card id="my-card" compact=true}
{#include components/data-display/card-title}Project Apollo{/include}
{#include components/data-display/card-body}Ship the dashboard by Q3.{/include}
{#include components/data-display/card-footer}Updated 2 hours ago{/include}
{/include}Family conventions:
- Content is each include's main block — not a named slot — so titles, bodies, and footers can hold any markup, and nothing can collide with slot names in the templates that wrap yours.
- Multi-region templates (e.g.
card-header) use component-prefixed slots (cardActions,cardSelectableActions) behindhas*guard params. - Roots own the standard Alpine contracts —
expandable=truewires expand state,selectedExpr="..."binds selection — and accept anattrsraw passthrough for custom Alpine directives. - Includes shadow inherited
id/attrsso they never leak into nested includes.
Every component can also be built from a typed Java model instead of template
parameters. The org.sitenetsoft.quarkus.pha.model package ships ~90
immutable builder classes (Alert, Card, Table, Wizard, Toolbar, …)
that a backend constructs and hands to the same include via a single
model parameter:
Alert alert = Alert.of("Deployment complete").variant("success")
.description("Your application is live.")
.actionLink("View deployment", "#").actionLink("Roll back", "#")
.build();{#include components/feedback/alert alert=alert /}The model branch of each template renders the full PatternFly anatomy —
including the Alpine.js wiring for expandable, selectable, and editable
states — so consumers describe components as data and never touch the
markup. Both modes stay supported: template parameters for quick one-offs,
models for anything data-driven. Composite families (card, page, wizard,
toolbar) take nested records (Card.Header, Wizard.Step,
Toolbar.Group, …) in place of slot composition, and models compose
across components (a Toolbar.Item can hold a Button or MenuToggle).
Every example on the demo pages has a Java tab showing the exact builder code that produced it; the props table on each page names the model parameter for that component.
Every component has a demo page with examples matching the patternfly.org docs,
a Qute-source viewer, and a props table: run the showcase (below) and browse
http://localhost:9090/.
The project ships a multi-layer test pipeline (lint, HTML/CSS/JS validation, type-check, server-side smoke + contract, Playwright E2E with console-error capture, axe a11y, HTMX target/header contracts, keyboard-nav, reference checks). Run everything with:
bash scripts/e2e.shReports land under .reports/ (gitignored). See TESTING.md for
the full breakdown of what each layer catches, how to run each one
standalone, and how to add a new test layer.
The component showcase lives in the integration-tests module and runs on port 9090.
Gradle needs Java 25 — the snippets below use the Debian/Ubuntu OpenJDK path;
adjust JAVA_HOME to your install (or omit it if your default java is already 25):
JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 \
./gradlew :quarkus-pha-integration-tests:quarkusDevThen browse http://localhost:9090/ for the component index.
The showcase app (the integration-tests module) packages like any Quarkus app:
JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 \
./gradlew :quarkus-pha-integration-tests:quarkusBuildIt produces integration-tests/build/quarkus-app/quarkus-run.jar, runnable with
java -jar integration-tests/build/quarkus-app/quarkus-run.jar.
The extension is native-image compatible — the full Playwright suite passes against the native binary. Build it (in a container, no local GraalVM needed):
JAVA_HOME=/usr/lib/jvm/java-25-openjdk-amd64 \
./gradlew :quarkus-pha-integration-tests:quarkusBuild \
-Dquarkus.native.enabled=true \
-Dquarkus.package.jar.enabled=false \
-Dquarkus.native.container-build=true(-Dquarkus.package.jar.enabled=false is required — Quarkus refuses to emit
both JAR and native outputs in one build.) The result is
integration-tests/build/quarkus-pha-integration-tests-1.0.0-SNAPSHOT-runner.
See https://quarkus.io/guides/gradle-tooling for more on native builds.
Every example fragment under integration-tests/.../templates/components/*/ is
wrapped in <div class="ws-preview-html">…</div>. The ws- prefix stands for
workspace — PatternFly's name for the docs-site preview area (see
patternfly/patternfly#887,
which references the original workspace.scss). It carries no CSS rule in
this repo — and none upstream either.
Why we keep it anyway: it's the same marker the official patternfly.org docs
site puts on its rendered HTML previews (see
example.js
— <div className={css('ws-preview-html', ...)} dangerouslySetInnerHTML={...} />).
PatternFly's docs framework leaves it unstyled on purpose so consumers have a
stable hook to target the preview area. Keeping the class means our example
markup is copy-paste compatible with PF's own examples, and we get the same
hook if we ever want to add preview-only styling.
If you ever need to add demo-only styling (e.g. consistent padding around the
example body), define .ws-preview-html { … } in pha.css rather than
inlining style="…" on each fragment.
Apache License 2.0 — see LICENSE. Contributions welcome: see CONTRIBUTING.md; security reports: see SECURITY.md.