Pointing a Horizon theme at Bootswatch darkly takes two @import lines, and the result looks finished for about a minute. Then you open a table and every second row is blank. The login page has a light grey strip across the top. The network topology sits on a white canvas, and every page load fetches fonts from Google. This guide builds an OpenStack Horizon dark theme that fixes each of those problems, with the cause traced to the exact SCSS or JavaScript line.
The theme is called Example Dark. It reuses the darkly palette that already ships inside Horizon, swaps the Google-hosted Lato font for the Roboto files bundled with Horizon, and patches the hard-coded colours that darkly never touches. It deploys with Kolla-Ansible or with the Ubuntu openstack-dashboard packages, and an optional last step adds a working dark and light toggle to the user menu. The steps ran in October 2026 on Kolla-Ansible 2026.1 (the stable/2026.1 Horizon image) on Rocky Linux 10 and on the packaged 2026.1 dashboard on Ubuntu 26.04 and 24.04. The same theme also compiled cleanly on Horizon 27.0.0, the 2026.2 release.
What breaks when a theme only imports darkly
Horizon ships no dark theme. An upstream change that adds one, review 997929, has been open since July 2026 and is not merged. So most operators reach for the Bootswatch darkly palette, which Horizon bundles under /horizon/lib/bootswatch/darkly. The obvious version is two files. _variables.scss imports the darkly variables and then the default theme variables. _styles.scss imports the default styles and then the darkly rules. We deployed exactly that first and walked through the dashboard. Six things were wrong:
| Symptom | Cause | Fix in this theme |
|---|---|---|
| Every even table row is white, with white text | $table-bg-even: #ffffff !default in the Horizon file dashboard/scss/_variables.scss. Darkly never sets it. | Set $table-bg-even to the body colour |
| Browser calls fonts.googleapis.com and fonts.gstatic.com | @import url($web-font-path) in the darkly _bootswatch.scss, pointing at Google Fonts Lato | Point $web-font-path at Horizon’s bundled Roboto |
| Light grey band above the login card | Angular Material injects html { background-color: #fafafa } at runtime | Set the html background |
| White Network Topology canvas | background: #ffffff on #topologyCanvasContainer | Override the canvas background |
| White Graph tab with invisible link lines | horizon.networktopology.js sets fill to white and an inline black stroke | CSS fill, plus stroke with !important |
| Unreadable OpenStack wordmark in the navbar and login card | The stock logo is dark grey text | Ship a light logo.svg and logo-splash.svg |
The table rows are the one that gets noticed first. Users, security group rules, networks and API endpoints all render half their rows as empty white bars. The text is there, white on white:

The login page shows the other two problems at once. The grey strip is the html element of the page showing through above the card, which sits 120 pixels down, and the wordmark under the OpenStack logo disappears into the panel:

The topology view keeps its white canvas because the colour is a literal in the Horizon SCSS, not a variable. Port address labels are drawn in $gray, which is readable on white and nearly invisible on a dark canvas, so they need a lighter colour once the canvas is fixed:

The font request is the one that bites air-gapped and privacy-sensitive clouds. Darkly hard-codes the Google URL as its default font path, and libsass hoists that @import to the top of the compiled CSS. The source line inside the Kolla Horizon container:
sudo docker exec horizon grep -rn 'web-font-path' /var/lib/kolla/venv/lib/python3/site-packages/static/horizon/lib/bootswatch/darkly/_bootswatch.scss
Lines 5 and 6 are the whole problem. A cloud with no outbound internet waits on that request until the browser gives up, and every other cloud tells Google about each dashboard visit:
5:$web-font-path: "https://fonts.googleapis.com/css?family=Lato:400,700,400italic" !default;
6:@import url($web-font-path);
A headless browser walking the dashboard with this naive theme hit one Google Fonts stylesheet on fonts.googleapis.com and three font files on fonts.gstatic.com, again on every page load. The finished theme below made zero external requests across 16 dashboard pages.
Before you start
This guide assumes a running Horizon and sudo on the host that serves it. Either of these works:
- A Kolla-Ansible deployment with Horizon enabled, run from the deploy host with the
all-in-oneormultinodeinventory. The Kolla-Ansible install on Rocky Linux 10 builds exactly that, and the Debian Kolla-Ansible guide uses the same layout. - Ubuntu 26.04 or 24.04 with the
openstack-dashboardpackage, served at/horizon. On 24.04, enable the Ubuntu Cloud Archive for 2026.1 first withsudo add-apt-repository cloud-archive:gazpacho, thensudo apt update && sudo apt full-upgrade. The stock 24.04 dashboard is the 2024.1 release, which compiles SCSS with pyScss and has nostatic_url()function, so the font override in this theme is not resolved there.
A theme only shows its problems on pages with data, so create at least one instance, a private network and a router before you start. The OpenStack command line client does that in a few commands. A DevStack cloud reads the same theme settings but keeps Horizon under different paths. Need a throwaway cloud to test on first? Our DevStack guide gets one running on a single VM.
If you have never built a Horizon theme, the custom Horizon theme guide explains the theme folder, the compress step and the logo files in more detail. This article reuses its Example Cloud logos and its stylesheet fix, which is why the dark theme screenshots still say Example Cloud. Proxmox admins who want the same treatment for their hypervisor UI can follow the Proxmox VE dark theme guide.
Set the variables
On the Kolla-Ansible deploy host, export these. THEME_SRC is the theme folder on the deploy host, THEME_DIR is where Kolla mounts it inside the Horizon container, HORIZON_URL is your dashboard address, and EDIT_DIR is where you edit files:
export THEME="exampledark"
export THEME_LABEL="Example Dark"
export THEME_SRC="/etc/kolla/config/horizon/themes/${THEME}"
export THEME_DIR="/etc/openstack-dashboard/themes/${THEME}"
export HORIZON_URL="https://openstack.example.com"
export EDIT_DIR="${THEME_SRC}"
On Ubuntu the theme lives straight under /etc/openstack-dashboard, so the edit folder and the theme folder are the same:
export THEME="exampledark"
export THEME_LABEL="Example Dark"
export THEME_DIR="/etc/openstack-dashboard/themes/${THEME}"
export EDIT_DIR="${THEME_DIR}"
Every file command from here uses ${EDIT_DIR}, so the same blocks work on both setups.
Write the dark theme
Create the folders. On Kolla:
sudo mkdir -p "${THEME_SRC}/static/img"
On Ubuntu:
sudo mkdir -p "${THEME_DIR}/static/img"
Open the variables file:
sudo vim "${EDIT_DIR}/static/_variables.scss"
Add the following. Order matters here. Darkly and Horizon declare their variables with !default, and a !default line only takes effect when nothing has set the variable yet. Plain assignments in this file come first, so they win. The font lines therefore go before darkly. $table-bg-even has to come after darkly, so it can use the darkly $body-bg. Horizon sets the white value later, in the dashboard SCSS it compiles after the theme variables:
// darkly imports Lato from fonts.googleapis.com. Use the Roboto files Horizon already ships.
$web-font-path: static_url() + "horizon/lib/roboto_fontface/css/roboto/roboto-fontface.css";
$font-family-sans-serif: "Roboto", "Helvetica Neue", Helvetica, Arial, sans-serif;
@import "/horizon/lib/bootswatch/darkly/variables";
// Horizon defaults this to #ffffff and darkly never sets it: every even table row turns white.
$table-bg-even: $body-bg;
@import "/themes/default/variables";
static_url() is a function Horizon adds to its libsass build. It expands to /static/ on both Kolla and Ubuntu, and the Roboto files it points at ship with every Horizon install. That one line turns the Google import into a local one.
Now the styles file:
sudo vim "${EDIT_DIR}/static/_styles.scss"
It imports the default styles, layers the darkly rules on top, and then overrides the hard-coded colours that darkly never touches:
@import "/themes/default/styles";
@import "/horizon/lib/bootswatch/darkly/bootswatch";
// Angular Material injects html { background-color: #fafafa }, visible above the login card.
html {
background-color: $body-bg;
}
// Network Topology canvases hard-code a white background.
#topologyCanvasContainer,
#flatTopologyCanvasContainer {
background: $body-bg;
}
// Port IP labels use $gray, which disappears on a dark canvas.
#topology_canvas .port_text {
fill: $gray-light;
}
// The Graph tab paints its background and link lines from JavaScript.
// The inline stroke style needs !important to lose.
#topologyCanvasContainer > svg > g > rect {
fill: $body-bg;
}
#topologyCanvasContainer line.link {
stroke: $gray-light !important;
}
The Graph tab needs the !important. Its link lines get style="stroke: black" straight from D3, and an inline style beats any normal stylesheet rule. The background rectangle is different. It uses a fill attribute, and a CSS rule already overrides that.
The last piece is a pair of light logos. Open the navbar logo:
sudo vim "${EDIT_DIR}/static/img/logo.svg"
White text on a transparent background, 32 pixels tall to fit the navbar:
<svg xmlns="http://www.w3.org/2000/svg" width="190" height="32" viewBox="0 0 190 32"><rect x="2" y="6" width="20" height="20" rx="4" fill="#4fa3f7"/><rect x="9" y="13" width="20" height="20" rx="4" fill="#ffffff" fill-opacity="0.85"/><text x="38" y="23" font-family="Helvetica, Arial, sans-serif" font-size="18" font-weight="700" fill="#ffffff">Example Cloud</text></svg>
Then the larger login card logo:
sudo vim "${EDIT_DIR}/static/img/logo-splash.svg"
Same design, scaled up:
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="60" viewBox="0 0 300 60"><rect x="4" y="10" width="36" height="36" rx="7" fill="#4fa3f7"/><rect x="16" y="22" width="36" height="36" rx="7" fill="#ffffff" fill-opacity="0.85"/><text x="66" y="42" font-family="Helvetica, Arial, sans-serif" font-size="30" font-weight="700" fill="#ffffff">Example Cloud</text></svg>
Use your own SVGs if you have them. The only rule is that the text must be light, because both logos sit on dark surfaces in this theme.
Deploy the theme with Kolla-Ansible
Register the theme in globals.yml. Kolla then copies the folder into the Horizon container and adds it to AVAILABLE_THEMES:
echo "horizon_custom_themes:
- name: ${THEME}
label: ${THEME_LABEL}" | sudo tee -a /etc/kolla/globals.yml
The appended block should read:
horizon_custom_themes:
- name: exampledark
label: Example Dark
This catches people out on clouds that already carry a custom theme. If globals.yml has a horizon_custom_themes: key from earlier work, do not append a second one. YAML keeps the last key, so the earlier theme silently drops out of the container. Add the - name: exampledark item to the existing list instead.
Next, make the theme the only selectable one and the default. The command appends to the custom settings file and keeps a backup, because that file often holds SSO or session settings too:
sudo cp -a /etc/kolla/config/horizon/_9999-custom-settings.py /etc/kolla/config/horizon/_9999-custom-settings.py.bak 2>/dev/null
echo "SELECTABLE_THEMES = [
('${THEME}', '${THEME_LABEL}', '${THEME_DIR}'),
]
DEFAULT_THEME = '${THEME}'" | sudo tee -a /etc/kolla/config/horizon/_9999-custom-settings.py
It echoes what it wrote:
SELECTABLE_THEMES = [
('exampledark', 'Example Dark', '/etc/openstack-dashboard/themes/exampledark'),
]
DEFAULT_THEME = 'exampledark'
On a multinode cloud, check for a per-host file under /etc/kolla/config/horizon/<inventory hostname>/. Kolla uses that file instead of the global one when it exists, so this step and the later toggle and override appends must all go there. Push the change. Only the Horizon role runs, and all-in-one is the inventory name from our lab, so swap in your own:
cd ~
source ~/kolla-venv/bin/activate
kolla-ansible reconfigure -i ./all-in-one -t horizon
A clean run ends with no failures. The container then restarts, notices the theme files changed, and recompiles the CSS on its own:
PLAY RECAP *********************************************************************
localhost : ok=30 changed=4 unreachable=0 failed=0 skipped=17 rescued=0 ignored=0
Don’t load the dashboard the moment the play finishes. The compress step runs during container startup, so give it about 20 seconds.
Deploy the theme on Ubuntu
The Ubuntu package reads extra settings from local_settings.d. Write the theme settings into their own file there. Unlike Kolla, Ubuntu needs AVAILABLE_THEMES set by hand, and the default theme must stay in that list or the build fails with File to import not found or unreadable: /themes/default/variables:
echo "AVAILABLE_THEMES = [
('default', 'Light', 'themes/default'),
('${THEME}', '${THEME_LABEL}', '${THEME_DIR}'),
]
SELECTABLE_THEMES = [
('${THEME}', '${THEME_LABEL}', '${THEME_DIR}'),
]
DEFAULT_THEME = '${THEME}'" | sudo tee /usr/share/openstack-dashboard/openstack_dashboard/local/local_settings.d/_20_custom_theme.py
The default theme is labelled Light here. That label only matters once the toggle at the end is enabled:
AVAILABLE_THEMES = [
('default', 'Light', 'themes/default'),
('exampledark', 'Example Dark', '/etc/openstack-dashboard/themes/exampledark'),
]
SELECTABLE_THEMES = [
('exampledark', 'Example Dark', '/etc/openstack-dashboard/themes/exampledark'),
]
DEFAULT_THEME = 'exampledark'
Collect the static files, compile, and reload Apache:
cd /usr/share/openstack-dashboard
sudo python3 manage.py collectstatic --noinput --clear -v0
sudo python3 manage.py compress --force
sudo systemctl reload apache2
After a couple of Python deprecation warnings that you can ignore, the compressor reports its blocks. Ubuntu 26.04 and 24.04 printed the same line:
Compressing... done
Compressed 7 block(s) from 14 template(s) for 2 context(s).
Confirm the dashboard makes no Google Fonts requests
The browser is the final judge, but curl gets you most of the way. On Kolla, this prints the last compress result and then checks every stylesheet the login page links for a Google URL and for the local Roboto import:
sudo docker logs horizon 2>&1 | grep '^Compressed' | tail -1
for css in $(curl -s "${HORIZON_URL}/auth/login/" | grep -oE '/static/dashboard/css/output\.[0-9a-f]+\.css' | sort -u); do
echo "${css} google=$(curl -s "${HORIZON_URL}${css}" | grep -c googleapis) roboto=$(curl -s "${HORIZON_URL}${css}" | grep -c roboto-fontface)"
done
Three stylesheets load on the login page. One of them is the theme, and it imports Roboto from /static. None of them mention Google. If your cloud uses a self-signed or Kolla-generated certificate, add -k to each curl, otherwise the loop prints nothing at all:
Compressed 7 block(s) from 21 template(s) for 3 context(s).
/static/dashboard/css/output.6c682f600600.css google=0 roboto=0
/static/dashboard/css/output.b597bc3299cf.css google=0 roboto=0
/static/dashboard/css/output.f8b0a5367868.css google=0 roboto=1
On Ubuntu the dashboard lives under /horizon while static files are served from /static, so the check uses localhost and builds the two paths separately:
for css in $(curl -s http://localhost/horizon/auth/login/ | grep -oE '/static/dashboard/css/output\.[0-9a-f]+\.css' | sort -u); do
echo "${css} google=$(curl -s "http://localhost${css}" | grep -c googleapis) roboto=$(curl -s "http://localhost${css}" | grep -c roboto-fontface)"
done
Ubuntu 26.04 gives the same counts. Two of the three hashes differ from Kolla because the Ubuntu build differs from the Kolla image, but the theme file again imports Roboto and nothing calls Google:
/static/dashboard/css/output.40bce484bb4c.css google=0 roboto=1
/static/dashboard/css/output.5ba17d986be2.css google=0 roboto=0
/static/dashboard/css/output.b597bc3299cf.css google=0 roboto=0
Run the naive two-import theme through the same check and the theme stylesheet shows google=1. For a browser-level test, open the developer tools network tab, filter on font, and reload. Every font request should go to your own hostname.
The dark theme across Horizon panels
The login page is now one colour edge to edge, with the light splash logo readable on the card:

Project Overview keeps the darkly blue for the used slice of each quota chart, and the rest of each pie stays grey:

The Instances table, with the filter bar and action buttons in the darkly greys:

Instance detail pages are definition lists rather than tables, so they never had the white-row problem:

The Launch Instance wizard is an AngularJS modal with its own transfer tables. It picks up the theme with no extra rules:

Network Topology on the dark canvas. The port addresses next to each network bar are the labels that needed $gray-light:

The Graph tab after the JavaScript overrides. The background is dark and the link lines are a light grey:

Security group rules, the table that exposed the white-row bug. Rows now alternate between the body colour and the darkly accent grey:

Identity Users, another long striped table:

The admin Hypervisors page, with summary charts for the compute host:

Ubuntu 26.04 renders the same theme from the packaged dashboard. Ubuntu 24.04 looked identical, so only one is shown:

Text inputs stay white with dark text. Darkly sets $input-bg: #fff on purpose, and it keeps form fields easy to spot on a dark page.
Let users switch between dark and light
A dark default with a light option is the friendly setup. Horizon builds the theme menu from SELECTABLE_THEMES, so listing both themes is enough to get the menu. It is not enough to get working colours. On Kolla, append the two-theme list. The later assignment wins over the one written earlier:
echo "SELECTABLE_THEMES = [
('${THEME}', '${THEME_LABEL}', '${THEME_DIR}'),
('default', 'Light', 'themes/default'),
]" | sudo tee -a /etc/kolla/config/horizon/_9999-custom-settings.py
On Ubuntu, rewrite the settings file with both themes selectable:
echo "AVAILABLE_THEMES = [
('default', 'Light', 'themes/default'),
('${THEME}', '${THEME_LABEL}', '${THEME_DIR}'),
]
SELECTABLE_THEMES = [
('${THEME}', '${THEME_LABEL}', '${THEME_DIR}'),
('default', 'Light', 'themes/default'),
]
DEFAULT_THEME = '${THEME}'" | sudo tee /usr/share/openstack-dashboard/openstack_dashboard/local/local_settings.d/_20_custom_theme.py
Rebuild (kolla-ansible reconfigure -i ./all-in-one -t horizon on Kolla, the collectstatic and compress block on Ubuntu) and pick Light from the user menu. The logo switches to the stock OpenStack one, but the colours stay dark:

That is the theme picker bug from the custom theme guide. Since Horizon 25.6.0 every theme links the same themes.scss URL, so the offline compressor keeps the CSS of only one theme. The fix is a global _stylesheets.html override that inlines the theme imports again, which gives each theme its own compress block. Create the overrides folder. Kolla:
sudo mkdir -p "${THEME_SRC}/overrides"
Ubuntu:
sudo mkdir -p "${THEME_DIR}/overrides"
Open the override template:
sudo vim "${EDIT_DIR}/overrides/_stylesheets.html"
Paste the template. Only the middle block differs from stock Horizon. It wraps the theme imports in {% with %} so the compressor sees a different block per theme:
{% load compress %}
{% load themes %}
{% compress css %}
<link href="{{ STATIC_URL }}horizon/lib/bootstrap_datepicker/datepicker3.css" type="text/css" media="screen" rel="stylesheet" />
<link href="{{ STATIC_URL }}horizon/lib/rickshaw/rickshaw.css" type="text/css" media="screen" rel="stylesheet" />
{% endcompress %}
{% current_theme as current_theme %}
{% theme_dir as theme_dir %}
{% with THEME=current_theme THEME_DIR=theme_dir %}
{% compress css %}
<style type="text/scss">
@import "/{{ THEME_DIR }}/{{ THEME }}/variables";
@import "/dashboard/scss/horizon.scss";
@import "/app/app";
@import "/{{ THEME_DIR }}/{{ THEME }}/styles";
</style>
{% endcompress %}
{% endwith %}
{% compress css %}
{% for file in HORIZON_CONFIG.scss_files %}
<link type="text/scss" href="{{ STATIC_URL }}{{ file }}" rel="stylesheet" />
{% endfor %}
{% endcompress %}
<link rel="shortcut icon" href="{% themable_asset 'img/favicon.ico' %}"/>
<link rel="apple-touch-icon" sizes="180x180" href="{% themable_asset 'img/apple-touch-icon.png' %}" />
<link rel="mask-icon" href="{% themable_asset 'img/safari-pinned-tab.svg' %}" color="#5bbad5" />
Put the overrides folder first in the template search path. Kolla:
echo "TEMPLATES[0]['DIRS'].insert(0, '${THEME_DIR}/overrides')" | sudo tee -a /etc/kolla/config/horizon/_9999-custom-settings.py
Ubuntu:
echo "TEMPLATES[0]['DIRS'].insert(0, '${THEME_DIR}/overrides')" | sudo tee -a /usr/share/openstack-dashboard/openstack_dashboard/local/local_settings.d/_20_custom_theme.py
Rebuild the same way as before. The block count goes up because each theme now compiles separately. Kolla reported:
Compressed 9 block(s) from 21 template(s) for 3 context(s).
Prove each theme gets its own stylesheet by requesting the login page with each theme cookie. On Kolla:
for t in ${THEME} default; do
echo "theme=${t}: $(curl -s -b theme=${t} "${HORIZON_URL}/auth/login/" | grep -oE '/static/dashboard/css/output\.[0-9a-f]+\.css' | sort -u | tr '\n' ' ')"
done
The two answers share two files and differ in the third. That third file is the theme:
theme=exampledark: /static/dashboard/css/output.6c682f600600.css /static/dashboard/css/output.b4b496e8567f.css /static/dashboard/css/output.b597bc3299cf.css
theme=default: /static/dashboard/css/output.07d10a369294.css /static/dashboard/css/output.6c682f600600.css /static/dashboard/css/output.b597bc3299cf.css
The Ubuntu version of the check calls localhost:
for t in ${THEME} default; do
echo "theme=${t}: $(curl -s -b theme=${t} http://localhost/horizon/auth/login/ | grep -oE '/static/dashboard/css/output\.[0-9a-f]+\.css' | sort -u | tr '\n' ' ')"
done
Ubuntu 26.04 and 24.04 returned identical hashes, which makes sense because both run the same packaged Horizon:
theme=exampledark: /static/dashboard/css/output.5ba17d986be2.css /static/dashboard/css/output.b597bc3299cf.css /static/dashboard/css/output.d23478a46f32.css
theme=default: /static/dashboard/css/output.156ed227d569.css /static/dashboard/css/output.5ba17d986be2.css /static/dashboard/css/output.b597bc3299cf.css
The user menu now lists both themes, with a tick next to the active one:

Choosing Light gives the full stock look, colours included:

The choice is stored in a theme cookie, so it follows the browser rather than the user account. A user who logs in from a second machine starts on the default, which is the dark theme.
Why the old sed dark-mode hack does nothing now
Older dark theme repositories, including the norcams darkblue theme, tell you to edit the _variables.scss that ships inside Horizon with a sed that replaces $table-bg-even: #ffffff;. Current Horizon no longer contains that string. The line now ends in !default, so the pattern matches nothing. sed -i still exits 0, and nothing tells you it changed nothing:
sudo docker exec horizon grep -rn 'table-bg-even:' /var/lib/kolla/venv/lib/python3/site-packages/openstack_dashboard/static/dashboard/scss/_variables.scss
sudo docker exec horizon grep -c '$table-bg-even: #ffffff;' /var/lib/kolla/venv/lib/python3/site-packages/openstack_dashboard/static/dashboard/scss/_variables.scss
The first command shows the real line. The second counts lines matching the old pattern, and the count is zero:
16:$table-bg-even: #ffffff !default;
0
That !default is good news. It lets the theme set $table-bg-even in its own _variables.scss, as this one does. Nothing inside the Horizon install or the Kolla image has to change, so nothing gets lost when the package or container is upgraded.
Hard-coded whites you can leave alone
A grep for white and #fff across the Horizon SCSS turns up more lines than the ones this theme overrides. Most of them are fine on a dark theme, and overriding them adds rules for no visible gain:
serial_console.scssstyles the serial console iframe as white text on a black terminal, which already suits a dark dashboard. The file is loaded on its own by the console page and is not part of the theme CSS._charts.scsstooltips use white text on an 80% black background. They read correctly on any page colour..rickshaw_legendhas a white background, but none of the 16 pages we opened renders a Rickshaw legend. Add a rule only if a plugin dashboard brings one back.- The
.main_navactive link in_legacy.scssstyles a horizontal nav that, according to a comment in that file, is no longer used. - Topology device boxes keep white frames with dark text in the Normal view. They stand out on the dark canvas the way node cards should.
After a Horizon upgrade, rerun the grep against the new release and compare it with the list above. A new hard-coded white is far more likely to arrive with a panel plugin than with core Horizon, and the topology and table fixes are the ones worth retesting first.