Skip to content

Write a theme

New

The Web UI’s colours are CSS custom properties. A theme is a folder with a manifest and one stylesheet that redefines the ones you care about. No build step, no JavaScript, no rebuild of octo — drop the folder in, reload the page, and your palette shows up in the theme picker.

~/.octo/themes/<id>/
├── manifest.json
├── theme.css
└── wallpaper-light.webp (optional — any assets your CSS references)

The folder name and the manifest’s id must match, and id must not be azure, the default pack that lives in the app itself.

The themes octo ships live here too. Ocean, Blossom and Vogue are written into this directory the first time octo runs, and nothing downstream tells them apart from yours — same format, same API. Copy one, rename the folder and its id, and you have a working theme to edit. Delete one and it stays deleted; edit one and an upgrade will not overwrite it.

{
"id": "ocean",
"name": "Ocean",
"author": "your-handle",
"homepage": "https://github.com/your-handle/octo-theme-ocean",
"names": { "zh": "深海" },
"swatch": ["#0E7490", "#F0F7F9"]
}
Field Required Notes
id yes Lowercase letters, digits and hyphens. Must equal the folder name.
name yes What the theme picker shows.
author no Shown in the picker’s tooltip.
homepage no Where people can find your repo.
names no Per-language overrides of name, e.g. {"zh": "深海"}. The picker uses the entry for the current UI language and falls back to name.
swatch no Accent and surface, in that order, for the chip the picker draws — your light palette. Hex only (#rgb, #rgba, #rrggbb, #rrggbbaa): the value reaches the page as a CSS custom property, so anything else is dropped. Without it the picker draws a neutral chip.

Two independent <html> attributes drive every colour:

  • data-theme — the resolved mode, light or dark.
  • data-theme-pack — the palette family. Absent for the default pack.

So a theme is two blocks: one for light, one for dark.

:root[data-theme-pack="ocean"] {
/* light values */
}
:root[data-theme-pack="ocean"][data-theme="dark"] {
/* dark values */
}

This is the one mistake every first theme makes. The default dark palette lives in :root[data-theme="dark"], which has the same CSS specificity as your :root[data-theme-pack="ocean"] block — and yours is injected afterwards, so it wins.

If you only write the light block, your light colours leak into dark mode and the UI comes out unreadable. Whatever you redefine for light, redefine for dark too.

63 custom properties make up the palette. You do not have to redefine all of them — anything you leave alone inherits the default pack — but the ones under Text, Backgrounds, Borders and Primary are what actually make a theme look like yours.

The accent family. --blue-6 is the main accent; the rest are its tints and shades.

--blue-1 --blue-2 --blue-5 --blue-6 --blue-7
--text --text-secondary --text-tertiary --text-quaternary --text-heading
--bg-layout --bg-container --bg-sidebar --bg-table-header --bg-zebra

--bg-zebra fills alternating rows of rendered markdown tables.

--border --border-secondary --border-table

Status colours. Each family has a fill, a background wash, a border and a text colour.

--success --success-bg --success-border --success-text
--warning --warning-bg --warning-border --warning-text
--error --error-bg --error-border --error-text --error-dark
--info-bg --info-border --info-text

--info-* is worth a note: in the packs that ship with octo it belongs to the accent family, not the status family. If you tint your accent away from blue, tint these with it.

--hover-neutral --row-hover --active-blue-bg --focus-ring --scrim

--focus-ring is an input’s focus box-shadow; --scrim is the backdrop behind modals and the command palette.

--chat-bg --chat-bg-image

Both are background-image layers, not colours — --chat-bg: #F5F5F7 will not work. Use a gradient:

--chat-bg: linear-gradient(170deg, #F7FCFF 0%, #ECF7FF 58%, #F7F1FF 100%);

--chat-bg-image is an optional wallpaper drawn over that gradient. Point it at a URL your theme folder serves, or leave it none.

--on-accent

Set this in every theme. It is the foreground colour for text and icons sitting on an accent fill — white by default, which only works if your accent is dark enough to carry it. The packs that ship with octo all had to override it: white reaches 2.6:1 over blossom’s rose, 2.3:1 over celestia’s sky blue, and 1.9:1 over vogue’s champagne gold — all below the 4.5:1 that WCAG AA asks for body text. If your accent is light or mid-tone, pick a dark --on-accent instead.

--radius-pill --radius-card --radius-modal --radius-sm --radius-xs

Geometry is a cheap way to give a theme character — the packs that ship with octo use rounder radii for a soft palette and near-square ones for an editorial look.

--sidebar-frost --panel-frost --titlebar-frost --frost-blur

The sidebar, slide-in panels and title bar. They default to opaque and flat (--frost-blur: 0px); raise the blur and make the frost colours translucent for a glassy shell.

--card-shadow card elevation
--terminal-bg --terminal-text command blocks — intentionally dark in BOTH modes
--surface-info plan cards, suggestions, subtle info backgrounds
--control-track segmented-control track
--search-bg --search-hover the header's search pill
--scrollbar-thumb --placeholder
--font-mono --font-sans font stacks
--font-zoom written by the font-size setting; do not set it
~/.octo/themes/ocean/theme.css
:root[data-theme-pack="ocean"] {
--blue-6: #0E7490;
--blue-5: #0891B2;
--blue-7: #155E75;
--text: #0F272E;
--text-secondary: #4A6C77;
--text-tertiary: #7E9BA4;
--bg-layout: #F0F7F9;
--bg-container: #FFFFFF;
--bg-sidebar: #F0F7F9;
--border: rgba(14,116,144,0.14);
--on-accent: #FFFFFF;
--chat-bg: linear-gradient(175deg, #F7FDFF 0%, #EDF7FA 100%);
}
:root[data-theme-pack="ocean"][data-theme="dark"] {
--blue-6: #22D3EE;
--blue-5: #67E8F9;
--blue-7: #0E7490;
--text: #E6F4F7;
--text-secondary: #9CB9C2;
--text-tertiary: #6B8892;
--bg-layout: #0B1A1F;
--bg-container: #10252B;
--bg-sidebar: #0B1A1F;
--border: rgba(34,211,238,0.16);
--on-accent: #06272E;
--chat-bg: linear-gradient(175deg, #0D1F25 0%, #0A171C 100%);
}

A theme can carry the files its stylesheet references — a wallpaper, a font. Put them in the theme’s folder and reference them by their full API path:

--chat-bg-image: url('/api/themes/ocean/wallpaper-light.webp');

Not a relative url(). A relative URL inside a custom property is resolved where the property is used, not where it is declared — and the app consumes --chat-bg-image from its own bundled stylesheet, so url('wallpaper-light.webp') would be looked for next to /assets/ and quietly fail. Your theme’s id is already in every selector it writes, so spelling the path out costs nothing. The same applies to @font-face sources and anything else your CSS loads.

Servable types: .css, .webp, .png, .jpg, .jpeg, .gif, .avif, .woff, .woff2, .ttf, .otf. SVG is deliberately not among them — it can carry script, and these files are served from the app’s own origin.

  1. Create the folder and the two files.
  2. Reload the Web UI.
  3. Open Settings and pick your theme in the Theme row.
  4. Switch the Appearance row between light and dark, and check both.

The two rows are independent: Theme chooses the palette family, Appearance chooses light or dark within it. That is why every theme needs both blocks.

Editing theme.css and reloading is the whole development loop. If your theme does not show up, check that the folder name matches id and that manifest.json parses.

Deleting the folder is uninstalling: anyone still on that theme falls back to the default pack.

  • Both blocks written — light and dark.
  • swatch set, so your theme is recognisable in the picker.
  • --on-accent passes 4.5:1 against your accent, in both modes.
  • --chat-bg is a gradient, not a colour.
  • Body text passes 4.5:1 against --bg-container and --bg-layout.
  • The theme survives a light/dark toggle without a reload.