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, npm and npx. Antora’s documentation advises an active long term support (LTS) release. The Default UI project is old: its .nvmrc file says 10 and package.json declares only node >= 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, about fs.Stats).

  • The Gulp command line. You can install gulp-cli globally with npm i -g gulp-cli, but the documentation prefers npx 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

src/layouts/

Page layouts: default.hbs and 404.hbs.

src/partials/

Handlebars partials the layouts include: head, header, nav, article, toc, footer, footer-content, footer-scripts and about twenty more.

src/helpers/

Handlebars helpers written in JavaScript: and, detag, eq, increment, ne, not, or, relativize and year.

src/css/

Stylesheets. site.css is the entry point and imports the others (vars.css, base.css, doc.css, nav.css and so on).

src/js/

Browser scripts, numbered so they concatenate in order (01-nav.js to 07-breadcrumbs.js). src/js/vendor is generated at build time.

src/img/

Images and icons used by the templates and the stylesheet.

preview-src/

The sample content for the preview: index.adoc, 404.adoc and ui-model.yml.

gulpfile.js and gulp.d/

The task definitions and the task implementations.

build/ and public/

Generated output, described in the next sections. Both are removed by the clean task.

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.

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

clean

Deletes the build and public folders.

lint

Runs lint:css (stylelint with the standard configuration, plus the project’s .stylelintrc) and lint:js (ESLint with JavaScript Standard Style) in parallel. Fails the task on any error.

format

Formats the JavaScript sources (the helpers, the Gulp files and the browser scripts) with prettier-eslint.

build

Compiles the sources in src/ into public/_: concatenated and minified CSS and JavaScript, processed images and copies of fonts, layouts, partials and helpers.

preview:build

Runs build and also renders the files in preview-src/ through the layouts into HTML pages in public/.

preview

Runs preview:build, then starts a web server on port 5252 and watches src/ and preview-src/, running preview:build again whenever a file changes.

bundle

Runs clean, then lint, then build, then bundle:pack. A lint error therefore stops the bundle.

bundle:pack

Only zips the staged public/_ folder into build/ui-bundle.zip. It does not clean, lint or rebuild, which is why it can run while preview is active without disturbing it.

pack

Deprecated alias of bundle.

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

antoraVersion

A version string, 1.0.0 in the sample.

site

url, title, homeUrl and components, a list of components, each with name, title, url, versions and latestVersion.

page

The current page: url, home, title, component, componentVersion, version, displayVersion, module, relativeSrcPath, editUrl, origin, previous and next for the pagination links, breadcrumbs, versions for the version selector and navigation, the nav tree.

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.

flowchart LR E["Edit src/ or preview-src/"] --> P["npx gulp preview: rebuild and refresh preview"] P --> L{"Looks right?"} L -->|no| E L -->|yes| B["npx gulp bundle: lint, build and zip"] B --> A["npx antora --ui-bundle-url=build/ui-bundle.zip"] A --> R{"Real pages OK?"} R -->|no| E R -->|yes| C["Commit and publish the bundle"]

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:

src/partials/footer-content.hbs
<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

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

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:

  • bundle and preview both write into public/_, and clean deletes build and public. Do not keep hand-made files there.

  • bundle:pack does not rebuild. If you changed a source file and ran only bundle:pack, the zip contains the previous build. Run build (or preview:build) first, or use bundle.

  • 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-lite notice from Browserslist, which you can silence with npx 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.