Building a Custom UI
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
An Antora UI is a ZIP file (or an extracted folder) of Handlebars templates, CSS, JavaScript, fonts and images that
Antora applies to the pages it converts. Writing that file by hand is possible, but the practical way to get one is to
start from the Default UI project, change it and build it with Gulp. This page explains that workflow: what you need
installed, how the project is organized, what every Gulp task does, how the preview site works, and how to try the
result on a real Antora site. It was written against the Antora Default UI at commit 0e38223 of its master branch
(the repository has no release tags) and Antora 3.2.1, and checked on 2026-10-26. The examples customize the footer of
the UI for the Pocket Units documentation, a tiny units-conversion library. For what a bundle contains and how Antora
loads one, see UI Bundle.
Why build from the Default UI
The Default UI repository (https://gitlab.com/antora/antora-ui-default) is an archetype: a complete, working UI together with the build that turns its sources into a bundle. Its documentation describes it as the starting point for a custom UI, either by working directly in a clone or by forking it. The alternative to a full fork is to keep the stock bundle and override single files with a supplemental UI, which is covered in Supplemental UI. A fork pays off when you change many files, want a linted and versioned bundle, or want to preview templates before they ever meet real content.
The Default UI is published under the MPL-2.0 license (the LICENSE file and the documentation both say so), so keep the
license file and the notices in your copy and read the terms before redistributing a modified bundle.
Prerequisites
The UI build is a Node.js project, so you need the following tools.
-
Git, to clone the project and to track your changes.
-
Node.js, which provides
node,npmandnpx. Antora’s documentation advises an active long term support (LTS) release. The Default UI project is old: its.nvmrcfile says10andpackage.jsondeclares onlynode >= 8.0.0. It nevertheless installed and built on Node.js 24.21.0 in the scratch environment used for this page, printing only a deprecation warning (DEP0180, aboutfs.Stats). -
The Gulp command line. You can install
gulp-cliglobally withnpm i -g gulp-cli, but the documentation prefersnpx gulp, which runs the Gulp version declared by the project once the dependencies are installed.
Checking the tools:
git --version
node --version
Clone and install
Clone the project, change into it and install the dependencies. npm ci installs exactly what package-lock.json
records; the official instructions use npm i, which also works and updates the lock file if a dependency has to be
resolved again. Commit the lock file whenever it changes so the build stays reproducible.
git clone https://gitlab.com/antora/antora-ui-default.git pocket-units-ui
cd pocket-units-ui
npm ci
npx gulp --tasks-simple
The last command lists the tasks the build supports. This is the output seen with this version of the project:
default
clean
lint
format
build
bundle
bundle:pack
preview
preview:build
pack
The official page lists the same tasks without pack, which the gulpfile.js marks as deprecated in favor of
bundle. During npm ci, recent npm versions print a warning that install scripts of some packages (fsevents,
jpegtran-bin and others) were skipped. The build still ran, but gulp-imagemin then reported that it could not load
its optipng plugin and minified the seven images with the plugins that did load. If you need every image optimizer,
approve the install scripts as the npm message explains.
If you want to keep your changes, work in a fork or on a branch of your own: the project’s documentation describes a workflow of a development branch, a pull request and an automated release triggered by merging to the default branch, which only applies to the Antora maintainers' own project. For your own UI, a plain Git repository is enough.
Project structure
The folders that matter, as found in the clone, are these.
| Path | Purpose |
|---|---|
|
Page layouts: |
|
Handlebars partials the layouts include: |
|
Handlebars helpers written in JavaScript: |
|
Stylesheets. |
|
Browser scripts, numbered so they concatenate in order ( |
|
Images and icons used by the templates and the stylesheet. |
|
The sample content for the preview: |
|
The task definitions and the task implementations. |
|
Generated output, described in the next sections. Both are removed by the |
The folder src/ becomes the bundle. The bundle root contains css, font, helpers, img, js, layouts and
partials; the font files come from the @fontsource packages, not from src/. How each of those folders is used
by Antora is explained in Templates and UI Model,
Helpers and
Stylesheets, Fonts and Static Files.
Source: Antora Default UI docs
The Gulp tasks
Run every task from the project folder with npx gulp <task>. The following table summarizes what the gulpfile.js
of this version does for each one.
| Task | What it does |
|---|---|
|
Deletes the |
|
Runs |
|
Formats the JavaScript sources (the helpers, the Gulp files and the browser scripts) with |
|
Compiles the sources in |
|
Runs |
|
Runs |
|
Runs |
|
Only zips the staged |
|
Deprecated alias of |
Previewing
npx gulp preview:build renders the preview pages once. These are the files it wrote in the scratch project:
public/404.html
public/index.html
public/multirepo-ssg.svg
public/_/css font helpers img js layouts partials
npx gulp preview builds the same output and then serves public/. The log of a real run (paths shortened) was:
[11:13:09] Using gulpfile <project>/gulpfile.js
[11:13:09] Starting 'preview'...
[11:13:09] Starting 'build'...
[11:13:09] Starting 'preview:build-pages'...
[11:13:09] Finished 'preview:build-pages' after 373 ms
[11:13:10] Finished 'build' after 531 ms
[11:13:10] Starting 'preview:serve'...
[11:13:10] Starting server...
[11:13:10] Server started http://localhost:5252 and http://192.168.86.40:5252
[11:13:10] Running server
Requesting http://localhost:5252/index.html returned status 200 with the title Hardware and Software Requirements ::
Brand Docs. After changing site.title in preview-src/ui-model.yml and saving, the log printed a new Starting
'build' and Starting 'preview:build-pages' pair about six seconds later and the page title in the served HTML
changed. The server listens on all interfaces (0.0.0.0), so the second URL in the log is reachable from other devices
on the network. Stop it with Ctrl+C.
The gulpfile.js turns on LiveReload only when the environment variable LIVERELOAD is true. With
LIVERELOAD=true npx gulp preview the log gains a line LiveReload started on port 35729, and that port served
livereload.js. This was observed without a browser attached, so the automatic refresh itself was not exercised here.
Without the variable, reload the page by hand after each rebuild.
The preview pages are an approximation, not Antora. They are rendered by the Gulp task, which converts each .adoc file
in preview-src/ with Asciidoctor.js and passes the result through the layout you ask for. Setting :page-layout:
in a preview file selects the layout (the task reads the page-layout attribute and falls back to default), and
404.adoc always uses the 404 layout. The task also sets the AsciiDoc attributes experimental, icons=font,
sectanchors and source-highlighter=highlight.js, and passes the process environment to the templates as env. That
last point lets you test conditional markup: running SITE_SEARCH_PROVIDER=lunr npx gulp preview makes
env.SITE_SEARCH_PROVIDER truthy in the templates, the same condition that the footer scripts partial checks. See
Search and Analytics Integration.
The sample UI model
The templates read data from a UI model that Antora builds for each page. In the preview, that model comes from
preview-src/ui-model.yml, which the task loads and extends with the converted page. It is plain YAML with these top-level
keys.
| Key | Content |
|---|---|
|
A version string, |
|
|
|
The current page: |
The task adds env, siteRootPath and uiRootPath for every page, and for pages other than the 404 it adds
page.attributes (the page attributes without their page- prefix), page.description, page.layout, page.title
and page.contents, the converted HTML. The complete list of variables a real build supplies is in
Templates and UI Model. When you add a template that
needs a variable the sample does not have, add it to ui-model.yml too; otherwise the preview shows the empty case and
the real site shows a different one.
Linting
npx gulp lint should finish silently apart from the task log. To see what a failure looks like, a line with an unused
variable and an extra semicolon was appended to src/helpers/year.js (and reverted afterwards):
<project>/src/helpers/year.js
5:5 error 'unused' is assigned a value but never used no-unused-vars
5:15 error Extra semicolon semi
5:15 error Missing whitespace after semicolon semi-spacing
3 problems (3 errors, 0 warnings)
2 errors and 0 warnings potentially fixable with the `--fix` option.
'lint:js' errored after 567 ms
'lint' errored after 568 ms
Because bundle runs lint before build, a bundle cannot be produced while the style rules are violated. Run
npx gulp format to fix JavaScript formatting automatically.
Building a bundle
Running npx gulp bundle cleans, lints, builds and zips. It took about 1.4 seconds in the scratch project and printed,
at the end, the ready-made Antora option for the file:
Antora option: --ui-bundle-url=<project>/build/ui-bundle.zip
Finished 'bundle' after 1.5 s
The result is build/ui-bundle.zip with about 74 entries (403 kB uncompressed): css/site.css, the Roboto web
fonts, js/site.js, js/vendor/highlight.js, the helpers, layouts, partials and images. The bundle has no ui.yml;
that optional file is described in Stylesheets,
Fonts and Static Files. The task prints the line Antora option only when the CI environment variable is not set.
The development loop
Preview and real-site checks complement each other, and the loop below uses both.
The figure above shows that the cheap preview comes first and the real Antora build is the gate before you publish.
Trying the bundle on a real site
The preview cannot show real content, multiple versions or the real navigation, so build the actual site with the
new bundle. The Antora playbook key ui.bundle.url accepts a local path as well as a URL, and the command line option
--ui-bundle-url overrides it for a single run, which is the quickest way to test without touching the playbook.
The following example changes the footer for the Pocket Units documentation. First the partial in the UI project is edited:
<footer class="footer">
<p>Pocket Units documentation, built with a custom UI.</p>
<p>The source code for this UI is licensed under the terms of the MPL-2.0 license.</p>
</footer>
Then the bundle is built and the Pocket Units site is generated with it. Both commands are run from the folders shown:
# in pocket-units-ui
npx gulp bundle
# in the Pocket Units documentation site
npx antora --ui-bundle-url=../pocket-units-ui/build/ui-bundle.zip antora-playbook.yml
Source: Antora docs: Specify a UI bundle
The generated page pocket-units/2.0/index.html then contained the new text, which confirms the full chain:
<footer class="footer">
<p>Pocket Units documentation, built with a custom UI.</p>
In this run the command was started from the folder that holds the playbook, so the relative path worked the same whether it came from the playbook key or from the command line option. If you start Antora from another folder, use an absolute path to avoid ambiguity.
Using an unzipped bundle or the preview output
Antora also accepts a folder in place of a ZIP file for a local bundle. This avoids zipping on every change and lets you read the exact files Antora will use. Both of these were tried against the same Pocket Units site and both produced the new footer:
# an extracted copy of the bundle
mkdir build/ui-unzipped
unzip -q build/ui-bundle.zip -d build/ui-unzipped
npx antora --ui-bundle-url=../pocket-units-ui/build/ui-unzipped antora-playbook.yml
# the staged output of preview:build or build, no zip involved
npx antora --ui-bundle-url=../pocket-units-ui/public/_ antora-playbook.yml
Source: Antora docs: Specify a UI bundle
Using public/_ is convenient while you iterate: npx gulp preview:build refreshes it in a second. For the final
check use the ZIP, because that is what you will publish.
A local bundle is read again at every build, so the snapshot key is irrelevant for it; it only matters for remote
URLs. See Publishing a UI Bundle for that distinction and
for hosting the bundle.
Required templates and common errors
Antora needs the default layout. When a bundle without layouts/default.hbs was used, the build stopped immediately:
{"level":"fatal","name":"antora","msg":"default layout not found"}
The 404 layout is optional for Antora itself. A bundle without layouts/404.hbs built successfully and the generated
site simply had no 404.html. The preview behaves differently, because it renders preview-src/404.adoc with the
404 layout; delete or adapt that file too if you remove the layout.
Other points that commonly trip people up:
-
bundleandpreviewboth write intopublic/_, andcleandeletesbuildandpublic. Do not keep hand-made files there. -
bundle:packdoes not rebuild. If you changed a source file and ran onlybundle:pack, the zip contains the previous build. Runbuild(orpreview:build) first, or usebundle. -
Preview-only changes (the sample model,
preview-src/) never reach the bundle, and the reverse: a template that works in the preview can fail on the real site when the UI model there lacks a variable the sample defines. -
The Default UI build is built on old dependencies (Gulp 4 and Browserify 16, for example). Expect deprecation warnings on current Node.js releases and an outdated
caniuse-litenotice from Browserslist, which you can silence withnpx update-browserslist-db@latest. -
Pin the commit or branch of the Default UI that you based your fork on, because the project publishes no tags.